Semantic Versioning for APIs in Practice: Deriving Major, Minor and Patch Correctly
AI generated
{ }
GET
REST API · Versioning · CI/CD
Semantic Versioning for APIs in Practice
Why an API version is not a library version, and how to derive the right bump automatically

For a library, a developer decides at release time whether a change is breaking and bumps the version number accordingly. For a REST API that is not enough: the contract between the server and dozens of unknown clients changes the moment the OpenAPI specification changes, and nobody on the team can manually track which of ten parallel feature branches just removed a required field. This article shows how to detect breaking changes automatically from OpenAPI diffs and enforce the correct version bump through CI, instead of relying on individual developers to remember.

15 min read SemVer · Breaking Changes OpenAPI Diff · CI Enforcement

1. Why SemVer behaves differently for APIs than for libraries

With an npm package or a Composer package, the consumer is known: they explicitly listed the library in their package.json or composer.json and control, through a version range, exactly when they pull an update. A major bump does not break anything immediately there, because the developer must actively run composer update to receive the new version and can review the change at their own pace. The version number is therefore primarily information for a human who makes a deliberate decision.

With a REST API the situation is reversed: a client calls a URL, and the moment the server starts returning a new response shape, every caller feels that change instantly and without warning, whether or not they were prepared for it. There is no lock file that pins an old version, and often the backend team does not even know all the consumers, especially for public APIs or internal systems that have grown organically over years. That is exactly why the version number of an API cannot just document a change, it has to actively gate access, typically through a URL path or a header that targets a specific contract version.

2. What actually counts as a breaking change for a REST API?

A breaking change exists whenever a client that strictly follows the previous OpenAPI specification stops working after the change. Classic cases include removing a response field a client reads, renaming a field, changing a data type (say, from string to integer), removing an enum value a client switches on, or adding a new required request parameter that existing clients naturally do not send. Changing the HTTP status code for the same error case also counts, if clients branch their error handling on that code.

Not every change that looks risky at first glance is actually breaking. A new optional field in the response breaks nothing under standard REST conventions, as long as clients ignore unknown fields, which is the default behavior of any well written JSON parser. Likewise a brand new endpoint is purely additive and therefore harmless, since no existing client calls it. The dividing line is not 'does something change' but 'can an existing, spec-compliant client fail because of this'. That distinction is the foundation of any automated detection, because it can be derived structurally from the OpenAPI document itself.

3. Drawing a clean line between minor and patch changes

A minor version signals additive, backward compatible extensions for an API: a new optional query parameter, an extra field in the response, a new endpoint, or an additional optional enum value that older clients simply do not know about and therefore ignore. A patch version, on the other hand, does not touch the contract at all, it fixes internal behavior: a bug in the business logic, a performance improvement, a fixed rounding error, as long as the OpenAPI specification remains structurally unchanged. In practice it pays off to make this boundary visible directly in the code, for example through attributes on Symfony controllers that document the minimum API version per endpoint.

The example below shows how such a minor extension can be marked cleanly in a Symfony controller, by explicitly documenting since which version the new, optional parameter is available. This annotation is not just for readability, a dedicated compiler pass or an OpenAPI generator can later evaluate it to check whether an endpoint declared as minor really only contains additive changes.


<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Attribute\ApiVersion;
use App\Dto\OrderCollectionFilterDto;
use App\Repository\OrderRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Returns a list of orders for the currently authenticated tenant.
 */
final class OrderListController
{
    public function __construct(
        private readonly OrderRepository $orderRepository,
    ) {
    }

    /**
     * Returns the order list. The optional "status" parameter was added
     * additively in version 2.3.0 (minor bump), since existing clients
     * keep working unchanged without it.
     *
     * @param Request $request The current HTTP request
     * @return JsonResponse The JSON serialized order list
     */
    #[Route('/api/v2/orders', methods: ['GET'])]
    #[ApiVersion(since: '2.3.0', breaking: false)]
    public function __invoke(Request $request): JsonResponse
    {
        $filter = OrderCollectionFilterDto::fromRequest($request);

        $orders = $this->orderRepository->findByFilter($filter);

        return new JsonResponse([
            'data' => $orders,
            'meta' => ['count' => \count($orders)],
        ]);
    }
}

4. Using OpenAPI diffs to derive the version bump automatically

The most reliable way to determine the correct version bump does not rely on a developer's self-assessment, it relies on a structural comparison of two OpenAPI documents. You export the specification of the last published state, generate the new document from the current branch, and let a diff tool compare both states path by path, parameter by parameter, and schema by schema. The result is a structured list of changes that falls into exactly three categories: additive and therefore minor, removing or type-changing and therefore major, or purely cosmetic (say, a changed description) and therefore without version impact.

From this classification the next version number can be derived mechanically, much like semantic-release does for libraries from commit messages, except that here the source of truth is not the commit text but the actual structure of the interface. That is a meaningful advantage over commit conventions like Conventional Commits, because a developer can misjudge or simply forget to add a 'BREAKING CHANGE' prefix while writing a commit message, whereas a structural diff of the OpenAPI file catches the change reliably regardless of how it was described.

5. Tooling for automated breaking change detection

The tool oasdiff has become the de facto standard for OpenAPI diffs in recent years, because it does not just report textual differences, it understands semantically that a reordered set of properties in a schema is irrelevant while removing a required field is critical. Running oasdiff breaking old.yaml new.yaml returns a list of concrete breaking changes with file path, affected endpoint, and a human readable description, and the process exit code directly signals whether a break exists at all, which fits automation extremely well.

Alternatives include openapi-diff from OpenAPITools or commercial solutions like Optic, which additionally visualize a history across multiple versions in a web interface. For a Symfony project that generates its OpenAPI specification through NelmioApiDocBundle, a two-stage process works well: the specification is stored as an artifact on every merge into the main branch, then a separate CI job compares that archived version against the version generated from the current feature branch. That produces a gap-free history that always lets you trace when a change was introduced and whether it was versioned correctly.

6. A CI pipeline that prevents version violations

Detection alone is not enough as long as it only produces a warning that gets buried in a pull request comment. The approach only becomes effective once the CI pipeline actively blocks the merge whenever a detected breaking change is not matched by an increased major version number. Concretely that means a pipeline step checking three things: first, whether oasdiff breaking reports any changes, second, whether the version file kept in the project (say a VERSION file or a Composer tag) actually contains a new major version, and third, whether the pull request explicitly carries a label such as breaking-change that forces a deliberate human confirmation.

If any of these three conditions is missing, the build fails, and the developer must either make the change genuinely backward compatible, for instance by adding an optional field instead of removing a required one, or deliberately bump the version number and document the consequences in the pull request description. This forced stop is inconvenient, and that is exactly the point: it prevents a breaking change from slipping through disguised as a harmless extension because a developer was under time pressure or simply did not grasp the full scope of the change. In larger teams this mechanism pays for itself within a few weeks, as soon as it has prevented its first silent production outage.

7. Comparing URL versioning, header versioning, and content negotiation

The version number derived from an OpenAPI diff eventually has to land somewhere in the actual HTTP traffic, and there are three established strategies for that. URL versioning, meaning a prefix like /api/v2/orders, is the simplest to implement and immediately visible to developers, but every new major version effectively requires a second, parallel route structure, and caching layers have to carry the version number as part of the URL. Header versioning through a dedicated header like Api-Version: 2026-08-07 or X-API-Version: 2 keeps the URL stable and works well for internal APIs, but requires clients to actively set that header, which is easy to forget.

Content negotiation via the Accept header, say Accept: application/vnd.mycompany.v2+json, is considered the 'purest' solution in the REST community because it follows the actual HTTP mechanism for format negotiation, but in practice it is rarely applied consistently, because it feels unfamiliar to frontend developers and many HTTP clients and proxies do not evaluate the Accept header as granularly as expected. In practice, most teams settle on a middle ground: URL versioning for major versions, combined with additive, backward compatible minor and patch changes within the same URL version, so that a major bump happens rarely enough to justify the overhead of a parallel route structure.

8. Using deprecation and sunset headers correctly

Once a new major version is live, the real challenge only begins: the old version has to be retired eventually without surprising clients. The Deprecation HTTP header signals since when an endpoint is considered outdated, while the Sunset header (per RFC 8594) states a concrete date after which the endpoint will no longer be reachable. Both headers can be set automatically in Symfony through an EventSubscriber on the ResponseEvent level for all routes marked as deprecated, so any team still using the old route sees the information directly in the HTTP response, without anyone having to write an email.

It also helps to add a Link header per RFC 8288 with rel="successor-version" that points straight to the documentation of the new version, so a client developer debugging an issue immediately finds the next step. In practice the gap between deprecation and actual shutdown should follow the release cadence of the most important consumers, six to twelve months is common for public APIs, while a few weeks is often enough for internal APIs with a small, known set of consumers. What matters is communicating this date firmly and encoding it technically in the Sunset header, instead of burying it in a wiki page nobody reads.

9. Practical example: SemVer enforcement in a Symfony project

In a concrete Symfony project, the full workflow looks like this: every merge into the main branch triggers a GitHub Actions job that first exports the current OpenAPI specification as a YAML file through NelmioApiDocBundle and archives it as an artifact. On every new pull request, another job downloads the most recently archived specification, generates the new version from the feature branch, and runs oasdiff breaking against both files. If the tool reports breaking changes, a small shell script checks whether the composer.json file in the same pull request already contains a new major version in its version field, and fails with a clear error message if it does not.

This setup has proven itself in practice because it does not require any extra infrastructure, builds directly on existing Composer and CI mechanisms, and gives developers immediate, understandable feedback instead of only surfacing the mistake in production. The most important effect is indirect: because breaking changes become visible and costly through the forced stop, teams gradually develop an instinct for shaping changes additively from the start, for example through new optional fields instead of renaming existing ones, which noticeably reduces how often a major version is even needed.

Change type Impact on clients Version bump Example
New optional response field No break, unknown fields are ignored Minor Field "discountPercentage" added
New endpoint No break, purely additive Minor POST /api/v2/orders/{id}/cancel
Required field removed from response Break, client parsing fails Major Field "legacyId" removed
Enum value removed Break, if a client switches on it Major Status "pending_review" removed
Bugfix without contract change No break, only internal behavior fixed Patch Incorrect rounding behavior fixed
New required request parameter Break, existing requests fail Major Field "tenantId" becomes required

Mironsoft

OpenAPI design, Symfony APIs, and API security

APIs that external teams can integrate without back-and-forth questions?

We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.

API Review

Checking the OpenAPI spec, error formats, and status codes for consistency.

Symfony Implementation

Using DTOs, Serializer, and Validator for clean, type-safe request/response models.

Security Audit

Hardening rate limiting, auth schemes, and input validation against real attack surfaces.

10. Summary

Semantic Versioning for APIs: The Key Points at a Glance

Patch

Internal behavior is fixed, the OpenAPI contract stays structurally unchanged.

Minor

Purely additive, backward compatible extension, existing clients keep working unchanged.

Major

A spec-compliant client can fail after the change.

Tooling

oasdiff compares two OpenAPI documents structurally and reports breaking changes in a machine readable form.

11. FAQ: Semantic Versioning for APIs: The Key Points at a Glance

1Can an API be versioned strictly per SemVer like a library?
Largely yes, but the definition of a breaking change differs: for an API, any change that makes a spec-compliant client fail counts as breaking, not every code change made in the backend.
2Does every API change need a new major version right away?
No. Only structural changes that could break existing clients require a major bump. Additive extensions like new optional fields or new endpoints are minor changes.
3What is the difference between semantic versioning and calendar versioning for APIs?
Semantic versioning encodes the type of change in the version number, calendar versioning (for example, a date as version) only encodes the point in time. Many large APIs, such as Stripe's, combine both by pairing date based versions with documented breaking changes per date.
4Isn't a manual review by an experienced developer enough to catch breaking changes?
It can work in small teams, but it does not scale: with parallel feature branches, high release frequency, or multiple teams, even an experienced reviewer eventually misses a change that an automated structural diff catches reliably.
5Which tool is best suited for OpenAPI diffs?
oasdiff has become the de facto standard because it detects semantic rather than purely textual differences and produces a clear exit code for CI integration. Alternatives include openapi-diff from OpenAPITools and commercial tools like Optic.
6How long should an old API version remain reachable after deprecation?
It depends on the consumers. For public APIs with unknown third parties, six to twelve months is common, while internal APIs with a few known consumers often only need a few weeks, as long as the sunset date is communicated early.
7What is the difference between the Deprecation header and the Sunset header?
The Deprecation header signals that an endpoint is considered outdated, without necessarily naming a concrete end date. The Sunset header per RFC 8594 states a concrete date after which the endpoint will no longer be reachable.
8Should breaking changes generally be avoided, even if they would be technically cleaner?
Not generally, but you should check whether the goal can be reached additively instead, for example through a new field rather than renaming an existing one. If a breaking change is unavoidable, it should be bundled deliberately and triggered rarely, instead of trickling out across many small releases.
9How do you make sure a pull request with a breaking change actually forces the major bump?
Through a CI job that runs oasdiff against the archived previous version and blocks the merge unless the project's version number (for example in composer.json) has been increased accordingly and an explicit label has been set.
10Does this approach also work for GraphQL or gRPC interfaces?
The underlying principle of structural diff detection transfers, though with different tools: GraphQL has its own schema diff tools, and gRPC/Protobuf has tools like buf breaking that build on the same idea as oasdiff does for REST.