Semantic versioning, or SemVer, is great. There are alternative idea’s like Jacob Tomlinson’s EffVer. In this article, I’ll show-case two internal contradictions of SemVer. However, besides all the criticism, I still think it’s the best versioning method we have. The two examples, are probably not relevant in practice and the fact that people fail to use SemVer consistently is not an argument against it.

So what is SemVer? In short, it’s a way to version software packages, especially libraries. The version is a dot-delimited triple of the major version, minor version, and patch version, e.g., 1.15.2.

  • When comparing two versions of the library that differ only in the patch version, it means that a bug has been fixed in a backward-compatible way. Software depending on the library is highly encouraged to upgrade to the latest patch. Why wouldn’t you want a bug-fixed dependency?
  • When comparing two versions of the library that differ only in their minor version, it means that new, backward-compatible features have been added. In Python, this could be adding new functions, new methods, new arguments, etc. Any Python function, class, etc. that existed before is still there and can be used. In SemVer, it’s considered safe to upgrade dependencies to their latest minor version.
  • When comparing two versions of the library that differ in their major version, it means that the more recent version contains backward incompatible changes. Upgrades of a dependency to a higher major version potentially breaks existing code.

Patches are breaking changes

It’s tempting to assume that upgrading a dependency to the latest version within the same minor version is safe and will not break existing code. After all, according to Semantic Versioning (SemVer), as long as the minor version remains the same, any changes introduced in patch releases should be backward compatible. However, in practice, this assumption can be risky.

Most patch releases are intended to fix bugs or security vulnerabilities, which often involves changing the behavior of the library. If the behavior wasn’t altered, the bug couldn’t be resolved. This introduces a subtle but critical problem: while the patch might correct a flaw, it could simultaneously disrupt existing code that relied, perhaps inadvertently, on the buggy behavior. For example, consider a function previously returned null in certain edge cases due to a bug. This behavior was corrected to return a more appropriate value or raise an exception, any code that expected null could fail after the patch is applied.

This issue becomes even more complex when considering what constitutes “buggy behavior.” Is it a bug if the documentation was unclear or silent on how the library should handle certain edge cases? In such cases, what one developer might see as a bug, another might have come to rely on as a feature. This ambiguity can lead to unintended breaking changes in what are supposed to be non-breaking releases. This could be attributed to bad documentation, but I argue any documentation is ambiguous to some extent (the only thing not ambiguous is the code).

Moreover, performance optimizations, such as reducing the runtime complexity of an algorithm from O(n^2) to O(n log n), are typically considered safe improvements and often categorized under patch releases. However, even these changes can have unintended consequences. For instance, a performance optimization might alter the timing of certain operations, leading to subtle race conditions or other timing-dependent issues such as triggering API rate limits in the software that relies on the library.

The first inconsistency or subtly in Semver is that every patch release is a breaking change.

This argument is put to extreme levels by the spacebar-heating XKCD.

Relying on the absence of features

According to SemVer, adding backward-compatible new features warrants a minor version release (e.g., from 1.2.0 to 1.3.0). The idea is that such changes should not break existing code that relies on the previous version.

However, this assumption of backward compatibility is tricky. Which changes are truly backward compatible? In certain scenarios, adding a new feature can inadvertently change the behavior of existing code, even if no breaking changes were introduced to a library. This is particularly problematic in dynamic languages like Python, which rely heavily on duck typing, i.e., a programming concept where the suitability of an object for a specific operation is determined by the presence of certain methods and properties, rather than the object’s type.

Consider the following Python code snippet:

import numpy

def func(obj):
    """Compute sqrt of object."""
    if hasattr(obj, "sqrt"):
        return obj.sqrt()
    return numpy.sqrt(obj)

In this example, func checks if the argument obj has the method sqrt. If it does, func calls obj.sqrt(). If not, it defaults to using numpy.sqrt(obj), i.e., a well-established function from the numpy library. This approach works smoothly under the assumption that obj either has an appropriate sqrt method or it doesn’t have a sqrt method.

Now, let’s imagine that you rely on a library (here library) that provides the class A. At the time you wrote the code, the class A did not provide a sqrt method. Consequently, func would consistently use numpy.sqrt() when passed an instance of A. However, if library is updated to a new minor version, and this update introduces a sqrt method to the class A, the behavior of func changes—potentially in unexpected and undesired ways. We cannot predict how sqrt will be implemented in the future. It might require additional arguments or provide additional metadata as return values.

The second inconsistency or subtly in Semver is that adding features is never backward-compatible.