Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions cep-XXXX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# CEP XXXX - Version strings and their ordering

<table>
<tr><td> Title </td><td> Version strings and their ordering </td>
<tr><td> Status </td><td> Draft </td></tr>
<tr><td> Author(s) </td><td> Jaime Rodríguez-Guerra &lt;[email protected]&gt;</td></tr>
<tr><td> Created </td><td> Sep 26, 2025 </td></tr>
<tr><td> Updated </td><td> Sep 26, 2025 </td></tr>
<tr><td> Discussion </td><td> N/A </td></tr>
<tr><td> Implementation </td><td> https://github.com/conda/conda/blob/6614653b1d9bdbffcef55e338d3220daed70c7f8/conda/models/version.py#L52, https://github.com/conda/rattler/blob/rattler-v0.37.4/crates/rattler_conda_types/src/version/mod.rs#L141 </td></tr>
</table>

## Abstract

This CEP describes version strings as used in the conda ecosystem, and their ordering.

## Motivation

The motivation of this CEP is mostly informative, but will also try to clarify some ambiguous details that should be homogenized across existing implementations.

## Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT",
"RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as
described in [RFC2119][RFC2119] when, and only when, they appear in all capitals, as shown here.

More specifically, violations of a MUST or MUST NOT rule MUST result in an error. Violations of the
rules specified by any of the other all-capital terms MAY result in a warning, at discretion of the
implementation.

### Version strings

[CEP 26](./cep-0026.md) only discussed the type of characters that can be part of a version string, and its maximum length:

> [...] version strings MUST only consist of digits, periods, lowercase ASCII letters, underscores, plus symbols, and exclamation marks. The maximum length of a version string MUST NOT exceed 64 characters.

The present CEP _extends_ these rules with additional constraints:

- Version strings MUST be composed of alphanumeric characters `[A-Za-z0-9]`, separated into segments by periods `.` and underscores `_`.
- The first segment MUST start with a digit.
- Empty segments (i.e. two consecutive dots, a leading/trailing underscore) MUST NOT be allowed.
- All segments SHOULD start with a digit for clarity.
- A single epoch number (a positive integer followed by `!`) MAY prefix the rest of the string.
- A single local version string MAY be added at the end, separated by a plus symbol `+`.

### Ordering

Before being compared, version strings MUST be parsed into a list of segments (with each segment being a list of components) as follows:

- They are first split into _epoch_, _main version_, and _local version_ at `!` and `+` respectively.
- If there is no `!`, the epoch is set to `0`.
- If there is no `+`, the local version is empty.
- The main version part is then split into components at `.` and `_`.
- Each component is split again into consecutive runs of numerals and non-numerals.
- Subcomponents containing only numerals are converted to integers.
- Strings are converted to lowercase, with special treatment for `dev` and `post`.
- When a component starts with a letter, the fill value `0` is inserted before the letter.
- The epoch and main version segments are concatenated.
- The same is repeated for the local version part, and stored as a separate list of segments.

For example:

```python
>>> parse("1.2g.beta15.rc")
[[0], [1], [2, 'g'], [0, 'beta', 15], [0, 'rc']], []
>>> parse("1!2.15.1_ALPHA")
[[1], [2], [15], [1, 'alpha']], []
>>> parse("1!2.15.1_alpha+1.2.3h123")
[[1], [2], [15], [1], [0, 'alpha']], [[1], [2], [3, 'h', 123]]
```

The resulting list of components MUST be compared as follows:

- Integers are compared numerically.
- Strings are compared lexicographically, case-insensitive. The substring `dev` is always smaller.
- Strings are considered smaller than integers, except for `post`, which is always greater.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that strings and number can never be compared, because of this:

When a component starts with a letter, the fill value 0 is inserted.

So, starting at 0, even indices will always contain a number and odd always a string

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I think you are right here 🤔 I wonder if this bit in the docs didn't get updated once they introduced the "fill value" solution.

Copy link
Contributor Author

@jaimergp jaimergp Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, given this script

from conda.models.version import VersionOrder as V

for version in (
    "0.dev1beta1.b1aa",
    "0.rc1BETA1.b1aa",
    "0.post0beta1.b1aa",
    "0.post1beta1.b1aa",
    "0post1beta1.b1aa",
):
    print(version, "->\n   ", V(version).version)

We get:

0.dev1beta1.b1aa ->
    [[0], [0], [0, 'DEV', 1, 'beta', 1], [0, 'b', 1, 'aa']]
0.rc1BETA1.b1aa ->
    [[0], [0], [0, 'rc', 1, 'beta', 1], [0, 'b', 1, 'aa']]
0.post0beta1.b1aa ->
    [[0], [0], [0, inf, 0, 'beta', 1], [0, 'b', 1, 'aa']]
0.post1beta1.b1aa ->
    [[0], [0], [0, inf, 1, 'beta', 1], [0, 'b', 1, 'aa']]
0post1beta1.b1aa ->  # I really hate this one
    [[0], [0, inf, 1, 'beta', 1], [0, 'b', 1, 'aa']]

See how dev is special cased to uppercase (every other string gets lowercased!), and post gets converted to inf. I guess this is a hack to make it work with Python's default ordering (uppercase sorts before lowercase, and inf is always larger). However, we do see a case here where letters and numbers need to be compared (albeit after internal conversions, so I don't think the rule applies and I guess we can remove it from the spec? But it doesn't hurt to keep it either).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think both the DEV and inf here should be considered implementation details.
In mamba this is handled with a custom comparison function for literals (it checks a few special cases and then defers to string comparison). In both C++ (mamba) and Rust (rattler), comparing a number and a string is not the "naive" thing to do.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also surprised about this. Pretty sure we don't have this behaviour in mamba. Should this be part of the spec? what should be the rule?

0post1beta1.b1aa ->  # I really hate this one
    [[0], [0, inf, 1, 'beta', 1], [0, 'b', 1, 'aa']]
     ☝️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That first [0] is the omitted 0! epoch, which is covered in the spec, I think?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha yes sorry, I got confused (the epoch is stored separately in mamba). I thought it was the first segment and that there was some sort of rule that "post" cannot be in the first segment...

- When a component has no correspondent, the missing component is assumed to be `0`.
- Local versions are only compared when the main versions are identical.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: if we could find some example of how local versions are used out in the wild

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found these in conda-forge/(osx-arm64,noarch):

$ conda search "*[version='*+*']"
Loading channels: done
# Name                       Version           Build  Channel
py-sirius-ms         2.1+sirius6.0.3    pyhd8ed1ab_0  conda-forge
py-sirius-ms         2.1+sirius6.0.4    pyhd8ed1ab_0  conda-forge
py-sirius-ms         2.1+sirius6.0.5    pyhd8ed1ab_0  conda-forge
py-sirius-ms         2.1+sirius6.0.6    pyhd8ed1ab_0  conda-forge
py-sirius-ms         2.1+sirius6.0.7    pyhd8ed1ab_0  conda-forge
py-sirius-ms         2.1+sirius6.0.7    pyhd8ed1ab_1  conda-forge
py-sirius-ms         3.0+sirius6.1.0    pyhd8ed1ab_0  conda-forge
py-sirius-ms         3.0.1+sirius6.1.0    pyhd8ed1ab_0  conda-forge
py-sirius-ms         3.1+sirius6.1.1    pyhd8ed1ab_0  conda-forge
r-sirius-ms          2.1+sirius6.0.4   r44h57928b3_1  conda-forge
r-sirius-ms          2.1+sirius6.0.4   r44h694c41f_1  conda-forge
r-sirius-ms          2.1+sirius6.0.4   r44ha770c72_1  conda-forge
r-sirius-ms          2.1+sirius6.0.5   r44h57928b3_1  conda-forge
r-sirius-ms          2.1+sirius6.0.5   r44h694c41f_1  conda-forge
r-sirius-ms          2.1+sirius6.0.5   r44ha770c72_1  conda-forge
r-sirius-ms          2.1+sirius6.0.6   r44h57928b3_1  conda-forge
r-sirius-ms          2.1+sirius6.0.6   r44h694c41f_1  conda-forge
r-sirius-ms          2.1+sirius6.0.6   r44ha770c72_1  conda-forge
r-sirius-ms          2.1+sirius6.0.7   r44h57928b3_0  conda-forge
r-sirius-ms          2.1+sirius6.0.7   r44h57928b3_1  conda-forge
r-sirius-ms          2.1+sirius6.0.7   r44h694c41f_0  conda-forge
r-sirius-ms          2.1+sirius6.0.7   r44h694c41f_1  conda-forge
r-sirius-ms          2.1+sirius6.0.7   r44ha770c72_0  conda-forge
r-sirius-ms          2.1+sirius6.0.7   r44ha770c72_1  conda-forge
r-sirius-ms          3.0.1+sirius6.1.0   r44h57928b3_0  conda-forge
r-sirius-ms          3.0.1+sirius6.1.0   r44h694c41f_0  conda-forge
r-sirius-ms          3.0.1+sirius6.1.0   r44ha770c72_0  conda-forge
r-sirius-ms          3.1+sirius6.1.1   r44h57928b3_0  conda-forge
r-sirius-ms          3.1+sirius6.1.1   r44h694c41f_0  conda-forge
r-sirius-ms          3.1+sirius6.1.1   r44ha770c72_0  conda-forge
r-sirius-ms          3.1+sirius6.1.1   r45h57928b3_1  conda-forge
r-sirius-ms          3.1+sirius6.1.1   r45h694c41f_1  conda-forge
r-sirius-ms          3.1+sirius6.1.1   r45ha770c72_1  conda-forge
typst-test           0.0.0.post105+699b871      h6e96688_0  conda-forge
typst-test           0.0.0.post105+699b871      h6e96688_1  conda-forge
typst-test           0.0.0.post106+2b4e689      h6e96688_0  conda-forge

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added it in a new Examples section.


> Warning:
> Pre-releases markers are sensitive to leading zeros and periods. While `"1.1.0" == "1.1.0.0" ==
> "1.1"`, the rule "When a component starts with a letter, the fill value `0` is inserted" results
> in `"1.1.0rc" == "1.1.rc" > "1.1rc"`. See [conda#12568](https://github.com/conda/conda/issues/12568).

## Rationale

- The first segment of a version must start with a digit to avoid confusing situations like `v13.2.1` being smaller than `3.1.0`. See [conda/conda#14255](https://github.com/conda/conda/issues/14255).
- The `dev` substring is handled differently to allow `dev` pre-releases to sort before alphas, betas and release candidates.
- The `post` substring is handled differently to allow `post` releases to sort after any equivalent final release.
- Missing components are treated like `0` to allow equivalences like `'1.1' == '1.1.0'`.
- The `0` fill value is used in components starting with letters to keep numbers and strings in phase, resulting in `'1.1.a1' == '1.1.0a1'`.

## Backwards compatibility

This CEP _extends_ [CEP 26](./cep-0026.md) with more details about version strings.

`conda` has historically allowed versions to start with a non-numeric character. This is now forbidden to avoid issues like `v10.4.5` being interpreted as smaller than `3.3.2`.

## Examples

The ordering specification results in the following versions sorted in this way:
Copy link

@h-vetinari h-vetinari Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could use a comment/example of how non-empty local versions are always ordered after (right?!) the ones with empty local versions.


```sh
0.4
== 0.4.0
< 0.4.1.rc
== 0.4.1.RC # case-insensitive comparison
< 0.4.1
< 0.5a1
< 0.5b3
< 0.5C1 # case-insensitive comparison
< 0.5
< 0.9.6
< 0.960923
< 1.0
< 1.1dev1 # special case ``dev``
< 1.1a1
< 1.1.0dev1 # special case ``dev``
== 1.1.dev1 # 0 is inserted before string
< 1.1.a1
< 1.1.0rc1
< 1.1.0
== 1.1.0.0
== 1.1
< 1.1.0post1 # special case ``post``
== 1.1.post1 # 0 is inserted before string
< 1.1post1 # special case ``post``
< 1996.07.12
< 1!0.4.1 # epoch increased
< 1!3.1.1.6
< 2!0.4.1 # epoch increased again
```

Local versions are not very common but there are some examples:

```text
$ conda search "*[version='*+*']"
Loading channels: done
# Name Version Build Channel
py-sirius-ms 2.1+sirius6.0.3 pyhd8ed1ab_0 conda-forge
py-sirius-ms 2.1+sirius6.0.4 pyhd8ed1ab_0 conda-forge
py-sirius-ms 2.1+sirius6.0.5 pyhd8ed1ab_0 conda-forge
py-sirius-ms 2.1+sirius6.0.6 pyhd8ed1ab_0 conda-forge
py-sirius-ms 2.1+sirius6.0.7 pyhd8ed1ab_0 conda-forge
py-sirius-ms 2.1+sirius6.0.7 pyhd8ed1ab_1 conda-forge
py-sirius-ms 3.0+sirius6.1.0 pyhd8ed1ab_0 conda-forge
py-sirius-ms 3.0.1+sirius6.1.0 pyhd8ed1ab_0 conda-forge
py-sirius-ms 3.1+sirius6.1.1 pyhd8ed1ab_0 conda-forge
r-sirius-ms 2.1+sirius6.0.4 r44h57928b3_1 conda-forge
r-sirius-ms 2.1+sirius6.0.4 r44h694c41f_1 conda-forge
r-sirius-ms 2.1+sirius6.0.4 r44ha770c72_1 conda-forge
r-sirius-ms 2.1+sirius6.0.5 r44h57928b3_1 conda-forge
r-sirius-ms 2.1+sirius6.0.5 r44h694c41f_1 conda-forge
r-sirius-ms 2.1+sirius6.0.5 r44ha770c72_1 conda-forge
r-sirius-ms 2.1+sirius6.0.6 r44h57928b3_1 conda-forge
r-sirius-ms 2.1+sirius6.0.6 r44h694c41f_1 conda-forge
r-sirius-ms 2.1+sirius6.0.6 r44ha770c72_1 conda-forge
r-sirius-ms 2.1+sirius6.0.7 r44h57928b3_0 conda-forge
r-sirius-ms 2.1+sirius6.0.7 r44h57928b3_1 conda-forge
r-sirius-ms 2.1+sirius6.0.7 r44h694c41f_0 conda-forge
r-sirius-ms 2.1+sirius6.0.7 r44h694c41f_1 conda-forge
r-sirius-ms 2.1+sirius6.0.7 r44ha770c72_0 conda-forge
r-sirius-ms 2.1+sirius6.0.7 r44ha770c72_1 conda-forge
r-sirius-ms 3.0.1+sirius6.1.0 r44h57928b3_0 conda-forge
r-sirius-ms 3.0.1+sirius6.1.0 r44h694c41f_0 conda-forge
r-sirius-ms 3.0.1+sirius6.1.0 r44ha770c72_0 conda-forge
r-sirius-ms 3.1+sirius6.1.1 r44h57928b3_0 conda-forge
r-sirius-ms 3.1+sirius6.1.1 r44h694c41f_0 conda-forge
r-sirius-ms 3.1+sirius6.1.1 r44ha770c72_0 conda-forge
r-sirius-ms 3.1+sirius6.1.1 r45h57928b3_1 conda-forge
r-sirius-ms 3.1+sirius6.1.1 r45h694c41f_1 conda-forge
r-sirius-ms 3.1+sirius6.1.1 r45ha770c72_1 conda-forge
typst-test 0.0.0.post105+699b871 h6e96688_0 conda-forge
typst-test 0.0.0.post105+699b871 h6e96688_1 conda-forge
typst-test 0.0.0.post106+2b4e689 h6e96688_0 conda-forge
```

## References

- [`conda 25.7.x` docs on Version Ordering](https://docs.conda.io/projects/conda/en/25.7.x/user-guide/concepts/pkg-specs.html#version-ordering).

## Copyright

All CEPs are explicitly [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/).

<!-- links -->

[RFC2119]: https://www.ietf.org/rfc/rfc2119.txt