Semver Ranges for Generated SDK Clients

Part of SDK Version Pinning within the API Versioning & Deprecation reference, this guide is about the range you put next to a generated SDK in a manifest — ^2.3.0, ~2.3.0, or an exact pin — and why that one choice decides whether a spec change silently upgrades a consumer or safely stops at a boundary. Generated clients are regenerated on every spec change, so the range operator is the contract between “pick up fixes automatically” and “never move without review.”

When You Need This

The decision arrives the first time a generated SDK is published to a registry and someone else depends on it:

Symptom / decision trigger Range decision it forces
A patch fix should reach consumers automatically Caret or tilde, not an exact pin
A breaking regen must never auto-upgrade anyone Major must bump; range stops at it
A 0.x SDK keeps breaking on minor bumps Exact pin or tilde, never caret
Builds differ between CI and a laptop A committed lockfile

The upstream trigger is the breaking-change signal from your diff pipeline. When API Changelog Automation flags an ERR-level change, the SDK major must increment — and the consumer’s range is what determines whether that increment quietly lands or deliberately blocks until someone opts in.

Range Operator Semantics

Semantic Versioning 2.0.0 gives MAJOR.MINOR.PATCH fixed meanings: major = breaking, minor = additive, patch = fix. Range operators build on that.

Caret, tilde, and exact range acceptance A version axis from 2.3.0 to 3.0.0. Caret 2.3.0 spans up to but not including 3.0.0. Tilde 2.3.0 spans only up to 2.4.0. Exact 2.3.0 marks a single point. A separate note shows caret 0.3.0 stopping at 0.4.0. 2.3.0 2.4.0 3.0.0 ^2.3.0 accepts 2.3.0 up to (not incl.) 3.0.0 ~2.3.0 patches only, < 2.4.0 2.3.0 a single version ^0.3.0 stops at 0.4.0 — caret guards the minor when major is 0

What ^2.3.0 Accepts

Caret’s rule is “do not change the left-most non-zero component.” For ^2.3.0 the left-most non-zero component is the major (2), so caret pins the major and lets minor and patch float:

^2.3.0  →  >=2.3.0  <3.0.0
  2.3.1   ✅  patch
  2.4.0   ✅  additive minor
  2.99.9  ✅  still major 2
  3.0.0   ❌  breaking — major changed

This is the right default for a stable SDK (>= 1.0.0): you automatically pick up new endpoints and fixes, and the range stops precisely where a breaking regen bumps the major.

The 0.x Special Case

Caret’s “left-most non-zero” rule bites for pre-1.0 SDKs. When the major is 0, the left-most non-zero component is the minor:

^0.3.0  →  >=0.3.0  <0.4.0     (guards the minor, not the major)
~0.3.0  →  >=0.3.0  <0.4.0     (identical here)

Under semver, 0.x is explicitly unstable — any minor bump may break. So even though ^0.3.0 refuses 0.4.0, the 0.3.x patches it does accept can still carry incompatible changes in a fast-moving generated SDK. For 0.x clients, pin exact or use ~, and read the changelog before every bump. The cleanest fix is to reach 1.0.0 quickly so caret regains its usual meaning.

Mapping API Majors to SDK Majors

The rule that makes ranges trustworthy: the SDK major tracks the OpenAPI info.version major. A breaking API change bumps info.version to the next major, the generator emits an SDK with a matching major, and every consumer’s caret range stops there until they opt in.

# openapi.yaml — a breaking release
openapi: 3.1.0
info:
  title: Orders API
  version: "2.0.0"   # was 1.7.0 — breaking change forced the major

npm

{
  "name": "@acme/orders-sdk",
  "version": "2.0.0",
  "dependencies": {
    "@acme/orders-sdk": "^2.3.0"
  }
}

PyPI (pyproject.toml)

Python has no caret operator; express the same window with a compatible-release or bounded constraint. The ~= operator is the closest analogue to caret at the minor level:

[project]
name = "acme-orders-sdk"
dependencies = [
  # >=2.3.0, <3.0.0 — the explicit form of a caret-style range
  "acme-orders-sdk>=2.3.0,<3.0.0",
]

~=2.3 means >=2.3, <3.0 (compatible release at the minor); ~=2.3.0 means >=2.3.0, <2.4.0 (tilde-like, patches only). Poetry additionally accepts the ^ syntax and expands it to the same bounds.

Go (import-path major encoding)

Go bakes the major version into the import path for v2 and up, so incompatible majors are different packages and can coexist during a migration:

// go.mod for the v2 SDK
module example.com/orders/sdk/v2

go 1.22
// consumer imports the v2 path explicitly
import sdk "example.com/orders/sdk/v2"

A consumer still on v1 imports example.com/orders/sdk (no suffix). Both can be imported in the same build while migrating call sites.

Lockfiles: Reproducibility Under a Range

A range decides what can resolve; a lockfile records what did. Commit the lockfile so CI, a teammate’s laptop, and production all install the identical resolved version regardless of new releases published in between.

Ecosystem Lockfile Pins
npm package-lock.json Exact version + integrity hash of every dependency
Poetry poetry.lock Resolved version + content hash
pip requirements.txt (with hashes) Exact == + --hash
Go go.sum Module version + cryptographic checksum
# Range lets the SDK float; the lockfile freezes the resolution.
npm install @acme/orders-sdk@^2.3.0   # writes package-lock.json
npm ci                                 # installs exactly what the lock says

Without a committed lockfile, ^2.3.0 can resolve to 2.3.1 today and 2.9.0 next week, so “works on my machine” becomes literally true. npm ci, poetry install, and go mod verify all read the lockfile, not the range.

Automating Updates with Renovate / Dependabot

Let a bot handle the churn, but split the flow by risk. Group minor and patch SDK bumps into one low-noise PR, and isolate majors so a human reviews the migration:

{
  "extends": ["config:recommended"],
  "packageRules": [
    {
      "matchPackageNames": ["@acme/orders-sdk"],
      "matchUpdateTypes": ["minor", "patch"],
      "groupName": "acme sdk (safe)",
      "automerge": true
    },
    {
      "matchPackageNames": ["@acme/orders-sdk"],
      "matchUpdateTypes": ["major"],
      "automerge": false,
      "labels": ["breaking-sdk"]
    }
  ]
}

Dependabot’s groups and ignore keys express the same policy. The principle is constant: patches and minors flow, majors gate. That mirrors what the range itself encodes — the bot just keeps the resolved version fresh within the window the range allows.

Caching and CI Implications

Cache dependencies by the lockfile hash, not the range string, so a resolution change busts the cache correctly (hashFiles('**/package-lock.json') in GitHub Actions, the equivalent for poetry.lock / go.sum). Use the frozen-install command in CI — npm ci, poetry install --no-update, go mod download — so a build can never silently drift to a newer SDK than the lockfile records. This keeps the resolved SDK version reproducible across every runner.

RFC and Standard Alignment

Standard Role
Semantic Versioning 2.0.0 Defines MAJOR.MINOR.PATCH and the 0.x instability clause
npm semver grammar Caret, tilde, and hyphen range semantics for the npm registry
PEP 440 Python version identifiers and the ~= compatible-release operator
Go Modules (minimal version selection) Import-path major encoding and go.sum checksums

SDK and Codegen Downstream Effect

When a breaking API diff forces the SDK major, consumers’ caret ranges stop moving — which is the point. Their manifest must be edited deliberately to cross the boundary:

  "dependencies": {
-   "@acme/orders-sdk": "^1.4.0"
+   "@acme/orders-sdk": "^2.0.0"   // deliberate opt-in to the breaking major
  }

Ship the migration notes generated by your changelog pipeline alongside that major so the consumer knows what changed. Consumers pairing this with a deprecation window — where the old endpoint still answers until its Sunset date — can run both SDK majors in parallel (trivially in Go via distinct import paths) until the retirement lands.

Common Mistakes

Mistake Correct approach
Using ^0.3.0 on an unstable 0.x SDK Pin exact or ~; reach 1.0.0 to make caret safe
Committing a range but no lockfile Commit package-lock.json / poetry.lock / go.sum
SDK major not tracking the API major Make info.version major drive the package major
Forgetting the /v2 suffix in a Go module path Encode the major in the module + import path
Auto-merging major SDK bumps Group minor/patch; gate majors for human review

FAQ

What does the caret range ^2.3.0 actually accept?

Caret allows changes that do not modify the left-most non-zero version component. For ^2.3.0 that component is the major, so it accepts any 2.x.y at or above 2.3.0 and stops before 3.0.0. It will install 2.4.0 or 2.9.5 automatically but never 3.0.0, which under semantic versioning signals a breaking change. This makes caret the right default for a stable >= 1.0.0 SDK — fixes and additive endpoints flow in, breaking regens do not.

Why is caret unsafe for 0.x SDK versions?

Caret protects the left-most non-zero component. When the major is 0, that component is the minor, so ^0.3.0 only accepts 0.3.x and refuses 0.4.0. But semver explicitly treats 0.x as unstable, where any minor bump may break — so even the 0.3.x patches caret does accept can carry incompatible changes in a fast-moving generated SDK. For 0.x SDKs, pin exact or use tilde, read the changelog before every bump, and prioritise reaching 1.0.0 so caret regains its normal guarantee.

How does Go handle a major version bump in a generated SDK?

Go encodes the major version in the import path for v2 and above. A v2 module declares module example.com/orders/sdk/v2 in go.mod, and consumers import example.com/orders/sdk/v2. Because the path differs, v1 and v2 of the same SDK can be imported simultaneously during a migration, and go.sum pins the exact cryptographic checksum of each resolved version so the build is reproducible.