SDK Version Pinning

Part of the API Versioning & Deprecation reference. This page covers how to pin generated SDK clients to specific API versions using semantic-version ranges — choosing between caret and tilde, committing lockfiles, mapping API majors to package majors, and wiring up automated upgrades that pull security fixes without dragging in breaking changes.

A generated SDK sits on a fault line. On one side is your API contract, which evolves on its own cadence. On the other is the consumer’s dependency graph, where a package manager decides — often at 3 a.m. in a CI runner — which version of your client library to install. Get the pinning strategy wrong and you fail in one of two directions. Understanding semver ranges for generated SDK clients is the foundation, and the version numbers themselves only mean anything if your publishing pipeline stamps them honestly — which ties directly into API Changelog Automation and the spec-diff tooling behind it.

Problem framing

There are two failure modes, and teams tend to overcorrect from one straight into the other.

Failure one: consumers auto-upgrade into a breaking SDK. A team floats their dependency — "@acme/api-client": "*" or a bare latest — and one morning a regenerated SDK ships a renamed method, a moved import path, or a field that changed from optional to required. The consumer’s build breaks with no code change on their side. Worse, if the SDK version number did not signal the break (a breaking change published as a minor bump), even a well-behaved caret range pulls it in silently. The root cause is usually a publishing pipeline that stamps versions by hand or by commit count rather than deriving them from the contract.

Failure two: consumers pin so hard they miss security fixes. Burned by failure one, a team pins an exact version — "@acme/api-client": "2.3.1" — and never moves. Six months later a CVE lands in a transitive dependency of the SDK, or the API deprecates an endpoint and ships a patch that adds a Deprecation header the client needs to surface. The pinned consumer never receives it. Hard pins also multiply across a fleet: forty services each frozen on a different patch of the same client, none of them upgradeable without individual manual work.

The resolution is not to pick a single number but to layer two mechanisms: a range in the manifest that expresses your upgrade policy, and a lockfile that freezes the exact resolved graph for reproducibility. The range says “I accept non-breaking upgrades of this SDK”; the lockfile says “but not until I regenerate it on purpose.” That layering only works if the SDK’s version numbers are trustworthy, which means the publishing side must map API majors to package majors mechanically.

Minimal spec-to-package mapping

Before any of the pinning logic matters, the SDK’s version must be derived from the contract, not chosen by a human. Here is the smallest honest mapping: the OpenAPI info.version drives the codegen config, which drives the published package version.

# openapi.yaml (OpenAPI 3.1.0) — the single source of truth for the version
openapi: 3.1.0
info:
  title: Orders API
  version: 2.4.0        # API semver — major 2 == breaking generation "v2"
  description: Orders and fulfilment API.
# openapitools.json equivalent — generator config that inherits the spec version
# packageVersion is templated from info.version at build time (see step 1 below)
generatorName: typescript-fetch
inputSpec: openapi.yaml
additionalProperties:
  npmName: "@acme/orders-client"
  npmVersion: "2.4.0"    # MUST equal info.version — enforced in CI, not typed by hand

The invariant to enforce everywhere: package major == API major. Package 2.4.0 of @acme/orders-client targets API v2. When the API cuts v3 with breaking changes, the SDK is published as 3.0.0, and the two client majors can coexist in the registry so consumers migrate on their own schedule.

API major to SDK major mapping

API major to SDK major mapping and caret range acceptance Top row shows API v1, v2, and v3 each mapping to SDK package majors 1.x, 2.x, and 3.x. Below, a number line for the caret range with a value of 2.3.0 shows that 2.3.0, 2.3.9, and 2.9.0 are accepted while 2.2.9 below and 3.0.0 above are rejected. API major API v1 API v2 API v3 SDK package client 1.x.x ^1.0.0 client 2.x.x ^2.3.0 client 3.x.x ^3.0.0 Range ^2.3.0 accepts 2.2.9 reject 2.3.0 accept 2.3.9 accept 2.9.0 accept 3.0.0 reject ≥ 2.3.0 and < 3.0.0

RFC and standard alignment

Version-range semantics are defined by the Semantic Versioning specification and implemented — with subtle differences — by each package ecosystem. Pin ranges against these rules, not against folklore.

Standard / clause Rule Implication for SDK pinning
semver 2.0.0 §4 Major version zero (0.y.z) is for initial development; anything MAY change at any time A 0.x SDK gives no compatibility guarantees; caret degrades to patch-only. Ship 1.0.0 early
semver 2.0.0 §9 Pre-release tags (-beta.1) have lower precedence than the normal version 2.4.0-rc.1 < 2.4.0; ranges exclude prereleases unless explicitly opted in
npm caret (^) Allows changes that do not modify the left-most non-zero element ^2.3.0 = >=2.3.0 <3.0.0; ^0.3.1 = >=0.3.1 <0.4.0
npm tilde (~) Allows patch-level changes if a minor is specified ~2.3.0 = >=2.3.0 <2.4.0; tighter than caret
PyPI / PEP 440 Compatible release operator ~= ~=2.3.0 = >=2.3.0, ==2.3.*; ~=2.3 = >=2.3, ==2.* — note the operator width depends on precision
Go modules (SemVer) Minimal Version Selection; import compatibility rule A new major is a new import path; go get never auto-crosses a major
Go major-version suffix Majors >=2 encode the major in the path (/v2) example.com/orders/v2 and .../v3 are distinct modules that coexist in one build

The three cross-ecosystem gotchas worth memorising: caret on 0.x is patch-only (semver §4), Python’s ~= width depends on how many components you write, and Go does not use ranges at all — it encodes the major in the import path and picks the minimum version that satisfies all requirers.

Implementation step 1 — publish one SDK per API major

Generate a separate package for each supported API major and stamp its version from the spec. The publish script reads info.version, refuses to publish if the package major disagrees with the API major, and pushes to the registry.

#!/usr/bin/env bash
# scripts/publish-sdk.sh — stamp SDK version from the OpenAPI contract
set -euo pipefail

SPEC="openapi.yaml"
API_VERSION="$(yq -r '.info.version' "$SPEC")"          # e.g. 2.4.0
API_MAJOR="${API_VERSION%%.*}"                          # e.g. 2

# Generate the client, injecting the spec version as the package version
npx @openapitools/openapi-generator-cli generate \
  -i "$SPEC" \
  -g typescript-fetch \
  -o "./dist/orders-client" \
  --additional-properties="npmName=@acme/orders-client,npmVersion=${API_VERSION}"

# Guard rail: package major MUST equal API major before we publish
PKG_MAJOR="$(node -p "require('./dist/orders-client/package.json').version.split('.')[0]")"
if [[ "$PKG_MAJOR" != "$API_MAJOR" ]]; then
  echo "ERROR: package major $PKG_MAJOR != API major $API_MAJOR" >&2
  exit 1
fi

( cd ./dist/orders-client && npm publish --access public )

The matching generator configuration keeps naming and package identity deterministic so re-runs produce byte-identical output except for the stamped version:

# openapitools.yaml — codegen config committed alongside the spec
generatorName: typescript-fetch
inputSpec: openapi.yaml
outputDir: ./dist/orders-client
additionalProperties:
  npmName: "@acme/orders-client"
  # npmVersion is overridden by the publish script from info.version
  supportsES6: true
  withSeparateModelsAndApi: true
  useSingleRequestParameter: true

For Go, “one package per major” is not a naming convention but a language rule. From v2 onward the module path carries the suffix, and the two majors are genuinely different modules:

// go.mod for API v2 — the /v2 suffix is mandatory (import-compatibility rule)
module github.com/acme/orders-client/v2

go 1.23

A consumer importing github.com/acme/orders-client/v2 and another importing .../v3 can share one build graph with zero conflict, which is exactly the isolation you want when migrating a large fleet across an API major.

Implementation step 2 — consumers pin a range plus lockfile

The consumer expresses policy with a caret range and freezes reality with a lockfile. In npm:

{
  "name": "checkout-service",
  "dependencies": {
    "@acme/orders-client": "^2.3.0"
  }
}

Commit package-lock.json and install with npm ci in CI so the exact resolved tree is reproduced. The caret means npm update can move you to 2.9.4 (a security patch or additive minor) but never to 3.0.0. The lock means it does not move until you run npm update on purpose and commit the new lockfile.

The Python equivalent with Poetry uses the caret directly; with plain pyproject.toml plus pip, use PEP 440’s compatible-release operator, remembering that its width depends on precision:

# pyproject.toml (Poetry) — caret behaves like npm's caret
[tool.poetry.dependencies]
python = "^3.11"
acme-orders-client = "^2.3.0"   # >=2.3.0, <3.0.0

# --- OR, PEP 621 + pip with the compatible-release operator ---
[project]
dependencies = [
  "acme-orders-client ~= 2.3",   # >=2.3, ==2.*  (allows minor+patch, blocks 3.0)
]

Commit poetry.lock (or a pip-compiled requirements.txt with hashes) and install with poetry install --sync or pip install --require-hashes. Note the deliberate choice: ~= 2.3 (two components) allows minor upgrades and blocks the next major, matching a caret; ~= 2.3.0 (three components) would allow only patch upgrades, matching a tilde. Pick the width to your policy.

Caret vs tilde in one sentence: caret (^2.3.0) tracks the whole major and is the right default for a generated SDK, because minors are additive-only under your publishing invariant; tilde (~2.3.0) tracks only the minor and is for consumers who want to review every minor bump — usually because they depend on generated types that a minor could widen.

Edge cases

Pre-1.0 0.x caret behavior. During a beta, an SDK often sits at 0.x. A consumer writing ^0.3.1 gets >=0.3.1 <0.4.0, not <1.0.0 — because semver §4 treats every 0.y bump as potentially breaking. Teams are routinely surprised that ^0.3.1 will not pick up 0.5.0. The fix is not a cleverer range; it is to graduate the SDK to 1.0.0 as soon as the contract stabilises so caret ranges span the major as intended.

Transitive SDK dependencies. A generated client pulls in a runtime (an HTTP library, a Pydantic or Zod version). A CVE in that transitive dep must be patchable without a consumer code change, which is exactly what the caret-plus-lockfile layering delivers: npm audit fix or Renovate bumps the transitive dep inside the lockfile while the SDK’s own range stays put. Publish SDKs with caret ranges on their own runtime deps for the same reason — never pin your generated client’s dependencies to exact versions, or you become the reason your consumers cannot patch.

Monorepo publishing. When several SDKs live in one repo, a changed-files check must scope version bumps to the packages whose spec actually changed. Tools like Changesets or Nx can bump @acme/orders-client without touching @acme/billing-client. Independent versioning is almost always correct here: coupling all SDK versions to a single repo version forces phantom majors on clients whose API did not change.

Prerelease tags. Ship release candidates as 2.5.0-rc.1. By semver §9 and default range semantics, ^2.3.0 will not resolve to a prerelease, so early adopters must opt in explicitly (npm install @acme/[email protected], or --include=prerelease in Renovate). This keeps prereleases off the auto-upgrade path while still letting you validate a new SDK against real consumers before the stable tag.

Validation and testing

Two automated gates keep the invariant honest.

Gate one — a breaking spec diff must force a major bump. Run an OpenAPI diff in CI and fail the release when a breaking change lands without a package major increment. This is the same tooling described in API Changelog Automation, pointed at a different assertion:

# .github/workflows/sdk-version-gate.yml
name: SDK Version Gate
on: [pull_request]
jobs:
  version-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - name: Diff OpenAPI against the base branch
        id: diff
        run: |
          npx @redocly/cli diff \
            "git:origin/main:openapi.yaml" openapi.yaml \
            --format json > diff.json || true
          # oasdiff exits non-zero on breaking changes; capture the classification
          npx oasdiff breaking \
            "git:origin/main:openapi.yaml" openapi.yaml \
            --fail-on ERR && echo "breaking=false" >> "$GITHUB_OUTPUT" \
            || echo "breaking=true"  >> "$GITHUB_OUTPUT"

      - name: Require a major bump when the diff is breaking
        run: node scripts/assert-major-bump.js "${{ steps.diff.outputs.breaking }}"
// scripts/assert-major-bump.js — fail if breaking change without a major bump
const { execSync } = require("node:child_process");
const breaking = process.argv[2] === "true";

const baseVersion = execSync("git show origin/main:openapi.yaml", { encoding: "utf8" })
  .match(/version:\s*([0-9]+)\./)[1];
const headVersion = require("fs")
  .readFileSync("openapi.yaml", "utf8")
  .match(/version:\s*([0-9]+)\./)[1];

const majorBumped = Number(headVersion) > Number(baseVersion);

if (breaking && !majorBumped) {
  console.error(
    `Breaking spec change detected but API major did not increase ` +
    `(${baseVersion} -> ${headVersion}). Bump info.version to the next major.`,
  );
  process.exit(1);
}
console.log(breaking ? "Breaking change with major bump — OK" : "Non-breaking — OK");

This gate is the linchpin: it is what makes a consumer’s caret range safe. Because a breaking change can never be published as anything less than a major, ^2.3.0 provably cannot resolve into a breaking client. Pair it with contract governance from Contract Linting & Governance so version discipline and style rules run in the same pipeline.

Gate two — Renovate/Dependabot grouping for safe upgrades. On the consumer side, configure the bot to auto-merge non-major SDK updates once tests pass, and to route majors to a manual review branch:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "packageRules": [
    {
      "matchPackageNames": ["@acme/orders-client", "acme-orders-client"],
      "matchUpdateTypes": ["minor", "patch"],
      "groupName": "acme sdk (non-major)",
      "automerge": true,
      "automergeType": "branch"
    },
    {
      "matchPackageNames": ["@acme/orders-client", "acme-orders-client"],
      "matchUpdateTypes": ["major"],
      "groupName": "acme sdk MAJOR — manual review",
      "automerge": false,
      "labels": ["breaking", "sdk-major"]
    }
  ]
}

The Dependabot analogue uses ignore with update-types: ["version-update:semver-major"] on the auto-merge group and a separate config for majors. Either way the principle holds: minors and patches flow automatically because your publishing invariant makes them safe; majors stop for a human because they change your code.

SDK generation impact

This entire topic is about SDKs, so the sharpest illustration is what a version bump does to generated code. When the API deprecates a field or operation, a good generator emits a @deprecated marker so the compiler warns the consumer before the endpoint is removed — feeding the Sunset & Deprecation Headers lifecycle at the type level. A minor bump carries these deprecations; the removal only lands in the next major.

// Generated by openapi-generator (typescript-fetch), client 2.6.0
export class OrdersApi {
  /**
   * List orders.
   * @deprecated since API v2.6 — use `searchOrders`. Removed in v3.0.
   * Sunset: 2026-12-31. See the Deprecation response header.
   */
  public listOrders(params: ListOrdersRequest): Promise<OrderPage> {
    // ...generated request code...
  }

  public searchOrders(params: SearchOrdersRequest): Promise<OrderPage> {
    // ...generated request code...
  }
}

A consumer on ^2.3.0 receives 2.6.0 automatically, and their TypeScript build now surfaces a deprecation warning on listOrders — visible, non-breaking, and actionable well before the endpoint disappears. The Python client mirrors this with warnings.warn(..., DeprecationWarning) emitted from the generated method body.

The major bump is where imports move. Publishing v3 as a new package (or a new Go /v3 path) means the migration is an explicit change of dependency identity, not a silent field rename:

// Before — consumer on API v2
import { OrdersApi, Order } from "@acme/orders-client";

// After — consumer migrated to API v3 (installed alongside v2 during migration)
import { OrdersApi, Order } from "@acme/orders-client-v3";
// or, if you publish one package with a major bump, the version in package.json
// jumps 2.x -> 3.x and the lockfile change is the reviewable migration artifact

Because v2 and v3 clients coexist, a large consumer can migrate call sites incrementally — importing both, moving one module at a time — rather than doing a big-bang cutover. That is the practical payoff of mapping API majors to package majors: the version number is not decoration, it is the mechanism that lets a breaking change be adopted deliberately instead of suffered accidentally.

Anti-patterns quick-reference

Anti-pattern Correct approach
Depending on latest or * for a generated SDK Pin a caret range (^2.3.0) and commit a lockfile
Pinning an exact version and never moving Caret range so security patches and additive minors flow within the major
Publishing a breaking change as a minor bump Gate the release on an OpenAPI diff that forces a major bump on breaking changes
Choosing the SDK version by hand or commit count Stamp packageVersion from info.version in the publish script
Shipping the stable SDK as 0.x long-term Graduate to 1.0.0 so caret ranges span the major as expected
Pinning the SDK’s own runtime deps to exact versions Use caret ranges on transitive deps so consumers can patch CVEs
Auto-merging major SDK updates Route majors to manual review; auto-merge only minor and patch
Coupling every SDK in a monorepo to one version Version each SDK independently by changed spec

FAQ

Should consumers pin an SDK with a caret range or an exact version?

Use a caret range in the manifest and commit a lockfile. The caret allows automated minor and patch upgrades, which carry additive-only API changes and security fixes, while the lockfile guarantees byte-for-byte reproducible installs in CI and production until you deliberately regenerate it. An exact pin without a lockfile gives you neither reproducibility across the transitive graph nor a path to security patches.

How do I map an API major version to a package major version?

Publish one SDK package per API major and make package major 1.x.x correspond to API v1, 2.x.x to API v2, and so on. Stamp the package version at generation time from the OpenAPI info.version field so a breaking spec change forces a package major bump automatically. In Go, the mapping is enforced by the language: majors from v2 onward carry a /v2 suffix in the import path.

Why does a caret range behave differently for 0.x SDK versions?

Under semver 2.0.0 §4, a caret on a 0.x version only allows patch-level changes because every 0.y release may contain breaking changes. So ^0.3.1 resolves within 0.3.x, not up to 0.9.x. Ship your first stable SDK as 1.0.0 to get predictable caret behavior across the whole major.