← Back to articles

Contracts in C++26: What They Check, What They Cost, When to Use Them

Modern C++ // dev April 20, 2026 9 min read
ModeMedian (ns/call)Relative
No contract0.1281.0×
Ignore0.1281.0×
Enforce2.1616.9×

That’s GCC 15.2.1, -O2 -std=c++26, on an i7-4790 at 3.6 GHz. The function under test does one multiply. One precondition is checked.

16.9× is not what you expect from a language feature whose selling point is “write the annotation, pay nothing in production.” And in ignore mode, you genuinely pay nothing — the assembly is byte-for-byte identical to code without contracts. But enforce mode tells a different story, and the reason is worth understanding.

The short version

C++26 contracts (P2900) give you [[pre:]], [[post:]], and [[assert:]] annotations. A build-time flag controls whether they’re ignored, observed (logged but non-fatal), or enforced (fatal). GCC 15 ships experimental support. I compiled against it, benchmarked the three modes, and read the generated assembly to understand the enforce overhead.

The ignore mode story is perfect. The enforce mode story is… educational.

Syntax

One important caveat first. GCC 15 implements the attribute-based syntax from earlier P2900 revisions. The latest P2900R9 draft specifies a keyword-based form (pre(cond), post(r: cond)). GCC 15 doesn’t support that yet. Everything here uses the attribute syntax. When compilers catch up to the final standard text, the spelling changes. The semantics don’t.

A function with all three contract forms:

#include <cmath>

double safe_sqrt(double x)
  [[pre: x >= 0.0]]
  [[post r: r >= 0.0 && r * r <= x * 1.000001]]
{
  return std::sqrt(x);
}

Precondition between the parameter list and the brace. Postcondition names the return value r. Both evaluated in the function’s parameter context.

Inline assertions look like this:

int bounded_add(int a, int b)
  [[pre: a >= 0 && b >= 0]]
{
  [[assert: a < 100'000'000]];
  [[assert: b < 100'000'000]];
  return a + b;
}

This compiles cleanly on GCC 15.2.1 with -std=c++26 -fcontracts. You need both flags — -fcontracts is not implied by the standard mode flag. I wasted ten minutes on that before checking the docs.

None of this is conceptually new. Eiffel had design by contract in 1986. The C++26 contribution is making these annotations visible to the compiler, with enforcement controlled by build flags rather than preprocessor macros.

What happens when a contract fails

Three modes, same source code, different runtime behavior. The compiler flags control everything.

Enforce: the program dies

With -fcontracts -fcontract-build-level=default:

void process(int x)
  [[pre: x > 0]]
{
  std::printf("processed %d\n", x);
}

int main() {
  process(-1);   // violates [[pre: x > 0]]
  std::printf("returned from process\n");
}

Output:

contract violation in function process at enforcement-demo.cpp:14: x > 0
terminate called without an active exception

Exit code 134. SIGABRT. The violation handler prints the function name, file, line, and the contract expression. Then std::terminate. Neither print statement in the code ever executes — the function body doesn’t run.

Observe: the program complains and keeps going

Same source, -fcontracts -fcontract-build-level=audit -fcontract-continuation-mode=on:

contract violation in function process at enforcement-demo.cpp:14: x > 0
[continue:on]
processed -1
returned from process

The handler fires, prints the diagnostic (GCC appends [continue:on] — that’s implementation-specific), but execution continues. The function body runs with the invalid input. Exit 0.

This is the migration mode. You plaster contracts across an existing codebase, build with observe, and watch the logs. When the contracts stop firing — meaning the contracts are correct, not necessarily the code — you switch to enforce.

Ignore: contracts vanish

With -fcontracts -fcontract-build-level=off, the compiler parses the annotations (syntax errors still caught) but generates nothing. Zero runtime cost. I verified this at the assembly level.

Inside the generated assembly

This is where I started having opinions.

Ignore mode

A minimal test function:

__attribute__((noinline))
int double_positive(volatile int x)
  [[pre: x > 0]]
{
  return x * 2;
}

Under -O2 -std=c++26 -fcontracts -fcontract-build-level=off, GCC 15.2.1 emits:

_Z15double_positivei:
    movl    %edi, -4(%rsp)
    movl    -4(%rsp), %eax
    addl    %eax, %eax
    ret

Four instructions. Load, load, double, return. No comparison, no branch. Byte-for-byte identical to compiling without the [[pre:]] attribute. I diff’d the object files. Same output.

Benchmark confirms: 0.128 ns/call with contracts, 0.128 ns/call without. Google Benchmark v1.9.1, 5 repetitions, medians indistinguishable.

This is actual zero overhead. Not “the overhead is small.” Zero.

Enforce mode: the .pre thunk

Same function, -fcontract-build-level=default:

_Z15double_positivei:
    subq    $24, %rsp
    movl    %edi, 12(%rsp)
    movl    12(%rsp), %edi
    call    _Z15double_positivei.pre    ; ← thunk call
    movl    12(%rsp), %eax
    addq    $24, %rsp
    addl    %eax, %eax
    ret

GCC doesn’t inline the contract check. It emits a separate function — a .pre thunk — and the original function calls into it before executing the body. The thunk:

_Z15double_positivei.pre:
    subq    $72, %rsp
    movl    %edi, 12(%rsp)
    movl    12(%rsp), %eax
    testl   %eax, %eax
    jle     .L3                         ; ← cold path: build violation object
    addq    $72, %rsp
    ret

The cold path at .L3 constructs a contract_violation object on the stack (72 bytes of setup), calls handle_contract_violation, then std::terminate. That path goes into .text.unlikely — good for the branch predictor on the happy path. But the call to the thunk itself is the problem. Stack adjustment, argument spill, function call, reload. On a one-instruction function body, that’s the entire cost profile.

2.16 ns versus 0.128 ns. For a function that does one multiply.

The overhead is a fixed cost per call, not proportional to the function body. For a function that parses a message, queries a hash map, or does any real work, 2 ns of contract checking vanishes into noise. For a hot leaf function called in an inner loop that does almost nothing — a comparator, a hash function, a tiny accessor — it’s measurable. This is the same tradeoff as assert() in debug builds, except the compiler knows what’s being checked and can (eventually) do better.

GCC 15’s thunk approach is a first implementation. I’d expect the check to get inlined in later versions. But today, this is what you get.

Environment: GCC 15.2.1, -O2 -std=c++26, Fedora 43, i7-4790 @ 3.6 GHz. Google Benchmark v1.9.1, 5 repetitions per measurement.

Where to put contracts

API boundaries

Contracts belong where your code meets code you don’t control. Public library functions. Subsystem entry points. Anywhere you’d previously write a comment saying “x must be positive” and cross your fingers.

class ring_buffer {
public:
  void push(const T& value)
    [[pre: !full()]]
  {
    // ...
  }

  T pop()
    [[pre: !empty()]]
    [[post r: size() == old_size - 1]]  // pseudocode; C++26 doesn't have 'old'
  {
    // ...
  }
};

One annoyance: C++26 contracts have no notion of “old” values. You can’t refer to pre-call state in a postcondition. The old_size - 1 above requires you to capture the value yourself. Eiffel had old in 1986. We’ll get there eventually.

Build matrix

Here’s what I’d actually deploy:

  • Dev builds: enforce. Every violation crashes. You find the bugs before CI does.
  • CI: enforce. Same reasoning. Your test suite becomes an implicit contract test suite for free.
  • Staging/canary: observe. Violations log but don’t crash. This is where you discover contracts that are too strict for production data patterns.
  • Production: ignore for hot paths, enforce for security-critical entry points. The -fcontract-build-level flag is translation-unit-wide, so you split critical code into separate TUs for per-module control.

This maps directly to what teams already do with assert() and NDEBUG, except you get the observe middle ground that macros never gave you.

Mid-function assertions

These catch logic errors that preconditions can’t reach:

void sort_and_merge(std::span<record> data) {
  std::ranges::sort(data, by_timestamp);
  [[assert: std::ranges::is_sorted(data, by_timestamp)]];

  merge_adjacent_duplicates(data);
}

The assertion after sort looks redundant. It’s not. If by_timestamp has a subtle comparator bug, this catches it at the point of failure instead of three calls later when the merge produces garbage. In ignore mode, the is_sorted call is eliminated entirely.

What contracts are not

Contracts are not input validation. [[pre: user_input > 0]] is a bug if user input can legitimately be negative — that’s a runtime check, not a contract. Contracts express conditions that must hold if the caller is correct. If the condition can legitimately fail, use an if or std::expected.

Portability is the other problem. GCC 15 has experimental support behind a flag. Clang 21 has no implementation yet, and MSVC hasn’t published a timeline. If you adopt today, you need #ifdef guards or a build system aware of -fcontracts. That’s the cost of being early.

What I’d do

Write contracts now, in the attribute syntax, on GCC 15. The syntax will change to keyword form when compilers catch up to P2900R9 — that’s a mechanical find-and-replace. The hard part is deciding what to check, and that doesn’t change with syntax.

Use ignore mode in production until GCC’s codegen matures. Enforce in dev and CI — contract violations should crash during development; that’s the point. Use observe when you’re migrating an existing codebase and aren’t sure whether the contracts or the code are wrong. That distinction — is the contract wrong or is the caller wrong — is the entire value of the observe mode, and it’s something assert() never gave you.