8/24/2026

A | B means either: reading union type hints in Python

Python · Type hints

def sensor_id(sensor: Sensor | int | None) -> int has three bars in one signature and looks like bit arithmetic. It is not arithmetic and it is not new syntax to memorise: each bar is the word or, and the whole annotation is one sentence — "a Sensor, or an int, or nothing".

Unions pile up in exactly one situation: a function that is polite about what callers hand it. A station can be identified by name or by number, a reading may be missing, an option may be left unset. Each politeness adds a member to the union, and the annotation grows to match. Reading one is a two-minute skill; the interesting part is what the notation does not promise, and that is where most of this post goes.

Everything below runs on CPython 3.10, and every output block is copied from a real run.

Contents

  1. One bar, two types — the whole of the notation
  2. The | None case — what used to be Optional
  3. Nobody checks it at runtime — annotations are documentation
  4. Narrowing a three-way union — the funnel pattern
  5. The bar builds a real object types.UnionType, and isinstance
  6. Where you put the bar changes the meaning list[int] | None vs list[int | None]
  7. Older Python from __future__ import annotations
  8. Traps
  9. Cheat sheet

1 · One bar, two types

The whole of the notation

A | B in an annotation means a value of type A, or a value of type B. That is the entire feature. It reads left to right, the order carries no meaning, and it chains: A | B | C is "any one of the three".

brightness.py
def brightness(level: int | float) -> str:
    return f"{level:.1f} lux"

print(brightness(3))
print(brightness(3.5))
$ python brightness.py
3.0 lux
3.5 lux

This spelling arrived in Python 3.10 (PEP 604). Before it, the same thing was written with a helper imported from typing, and you will still meet that spelling in any code that supports older interpreters:

ModernPre-3.10 equivalent
int | floatUnion[int, float]
int | NoneOptional[int]
str | int | NoneOptional[Union[str, int]]
dict[str, float] | NoneOptional[Dict[str, float]]

They are not merely similar, they compare equal — the bar is a different way to spell the same object:

equivalence.py
from typing import Optional, Union

print(int | None == Optional[int])
print(int | str == Union[int, str])
print(str | None == None | str)      # order is not part of the meaning
$ python equivalence.py
True
True
True

Which explains why nobody writes the old form in new code, and why a linter will rewrite it for you. Ruff's UP rules do it as a fix:

old_style.py
from typing import Optional, Union


def humidity(sensor: Optional[int]) -> Union[float, str]:
    if sensor is None:
        return "no sensor"
    return 41.5
$ ruff check --select UP --target-version py310 --diff old_style.py
--- old_style.py
+++ old_style.py
@@ -1,7 +1,7 @@
 from typing import Optional, Union


-def humidity(sensor: Optional[int]) -> Union[float, str]:
+def humidity(sensor: int | None) -> float | str:
     if sensor is None:
         return "no sensor"
     return 41.5

Would fix 2 errors.

UP045 is the Optional one, UP007 the Union one. Both are auto-fixable, so a whole codebase converts in one command once every supported interpreter is 3.10 or newer.

2 · The | None case

"or nothing" is the common member

By far the most frequent union is X | None, because "this may be absent" is the most frequent extra case in any real signature. None here is standing in for its type, NoneType, which is why it can appear in a union at all:

nonetype.py
print(type(None))
print(int | None == int | type(None))
$ python nonetype.py
<class 'NoneType'>
True
Convention

Put None last. int | None and None | int are the same type, but the reader parses "an int, possibly absent" faster than "absent, or an int", and every style guide and auto-fixer produces the former.

A parameter with a None default should say so in the annotation. The default does not add the member for you:

wrong
def scale(factor: float = None):
    # claims float, accepts None
    ...
right
def scale(factor: float | None = None):
    # the annotation matches the default
    ...

The wrong version runs perfectly. It is a documentation bug: the annotation says the argument is always a float while the default proves otherwise, so a reader who trusts it will skip the None branch they needed to write. A type checker flags it; the interpreter never will.

3 · Nobody checks it at runtime

Annotations are documentation with syntax

This is the single most important fact about the notation, and the one that surprises people coming from a compiled language. Writing int | str does not make the interpreter reject a list. Nothing is validated on the way in, and nothing is validated on the way out.

unchecked.py
def label(value: int | str) -> str:
    return value.upper()

print(label("dry"))
print(label(7))
$ python unchecked.py
DRY
AttributeError: 'int' object has no attribute 'upper'

The annotation was honest — 7 really is an allowed argument — and the body was not. It called a string method on a value the signature promised might be an integer. The union is a claim about the caller; honouring it inside the body is the author's job, and that job is called narrowing.

Consequence

Every union member you add is a branch you owe the body. A three-member union with one code path through it is a bug waiting for the second-most-common input.

4 · Narrowing a three-way union

The funnel pattern

When a signature accepts several shapes, the usual body converts them to one shape as early as possible and then gets on with the actual work. The union is wide at the top of the function and narrow everywhere after.

  sensor: Sensor  |  int  |  None     three shapes in, one int out
            │         │       │
            │         │       │       isinstance(sensor, Sensor)
            ▼         │       │
       sensor.index   │       │
       (int | None)   │       │
            │         │       │
            └────┬────┘       │
                 │            │
                 ▼            ▼
                int          None     Sensor(None) lands here too
                 │            │
                 ▼            ▼
           return sensor   return 0
narrow.py
class Sensor:
    def __init__(self, index: int | None) -> None:
        self.index = index


def sensor_id(sensor: Sensor | int | None) -> int:
    if isinstance(sensor, Sensor):
        sensor = sensor.index      # Sensor | int | None  ->  int | None
    if sensor is None:
        return 0                   # int | None  ->  int
    return sensor


print(sensor_id(Sensor(4)))
print(sensor_id(9))
print(sensor_id(None))
print(sensor_id(Sensor(None)))
$ python narrow.py
4
9
0
0

Two lines removed two members. The first if replaces a Sensor with the integer inside it, so after that line the variable is int | None even though the parameter was declared wider. The second turns the remaining None into a value, so the return sensor at the bottom is an int and matches the return annotation. A type checker follows this reasoning statically and will complain if a branch is missing.

Read the fourth line of output

sensor_id(Sensor(None)) returns 0, the same as passing nothing at all. That is not a bug here, but it is a decision, and it is the kind of decision a wide union hides: an object whose interior field is empty collapses onto the same branch as a missing object. If those two cases should behave differently, the union has to be narrowed in a different order.

Ordering the checks is not narrowing by itself

isinstance and is None narrow. Falling off the end of a chain of ifs also narrows, by elimination, and is idiomatic — but only if the members really are exhausted:

elimination.py
def to_text(value: int | str) -> str:
    if isinstance(value, int):
        return f"#{value}"
    return value.strip()          # nothing left but str

print(to_text(7), to_text("  wet  ") + "|")
$ python elimination.py
#7 wet|

5 · The bar builds a real object

Not a special form the parser swallows

int | None is an ordinary expression evaluated at runtime. It produces an instance of types.UnionType, it has a readable repr, and it can be stored in a variable like any other value:

runtime_object.py
print(type(int | None))
print(int | None)

Celsius = float | int                    # a reusable alias
Reading = dict[str, float] | None
print(Celsius)
print(Reading)
$ python runtime_object.py
<class 'types.UnionType'>
int | None
float | int
dict[str, float] | None

Because it is a real object, duplicates collapse and nesting flattens, which is why you never see a union of unions:

flatten.py
print(int | str | int)
print((int | str) | float)
print(int | None | None)
$ python flatten.py
int | str
int | str | float
int | None

It works in isinstance

A genuine convenience of the new spelling: the resulting object is accepted as the second argument to isinstance and issubclass, which Union[...] never was. It behaves like the tuple form.

isinstance_union.py
print(isinstance(3, int | float))
print(isinstance("x", int | float))
print(isinstance([1], list | None))
print(isinstance([1], list[int] | None))    # parameterized -> rejected
$ python isinstance_union.py
True
False
True
TypeError: isinstance() argument 2 cannot contain a parameterized generic

The last line is the general rule about runtime checks, not a union quirk: nothing can test list[int] at runtime, because that would mean walking the list. Check list and trust the annotation for the element type.

Types only

The bar between two instances is still plain __or__, and most objects do not implement it. 3 | None raises TypeError: unsupported operand type(s) for |: 'int' and 'NoneType'. Unions are built from types, and a value that happens to be a type — a variable holding int — works exactly as well as the literal.

6 · Where you put the bar changes the meaning

Outside or inside the brackets

Two annotations one character apart in appearance, and entirely different in what they permit:

AnnotationReads asAcceptsRejects
list[int] | None a list of ints, or no list at all [1, 2], [], None [1, None]
list[int | None] always a list, whose items may be missing [1, None], [] None

The structure is visible at runtime — the outer construct is different in each case:

nesting.py
from typing import get_args, get_origin

print(get_origin(list[int] | None), get_args(list[int] | None))
print(get_origin(list[int | None]), get_args(list[int | None]))
$ python nesting.py
<class 'types.UnionType'> (list[int], <class 'NoneType'>)
<class 'list'> (int | None,)

For the first, the outermost thing is the union and the list is a member. For the second, the outermost thing is a list and the union is its element type. When a signature needs both — an optional list of optional readings — say so: list[float | None] | None. It is a mouthful, and that is a fair warning about the interface.

7 · Older Python

The from __future__ escape

On 3.7 through 3.9 the bar between two types raises TypeError when evaluated. Since annotations are evaluated at function-definition time by default, that makes def f(x: int | None) a runtime error on those versions — not a warning, a crash on import.

from __future__ import annotations, at the top of the module, stops annotations from being evaluated at all. They are stored as strings and only ever inspected by tools:

future_import.py
from __future__ import annotations


def calibrate(offset: int | None) -> float | None:
    return None if offset is None else offset * 0.5


print(calibrate.__annotations__)
print(calibrate(4), calibrate(None))
$ python future_import.py
{'offset': 'int | None', 'return': 'float | None'}
2.0 None

The values are strings, quotes included. Nothing ever evaluated int | None, so the syntax is free on any version that has the future import.

Only annotations

The future import covers annotation positions and nothing else. An alias assignment (Celsius = float | int), a call to isinstance(x, int | None), or anything that resolves annotations at runtime — typing.get_type_hints, and the validation libraries built on it — still evaluates the expression and still fails below 3.10. Those places need Union and Optional until the floor moves.

8 · Traps

Four that survive the nicer syntax

A wide union is a cost, not a courtesy

str | int | Path | None is four branches for every caller and for the body. If one of the members is only there because a single caller was lazy, converting at that call site is usually cheaper than teaching every reader of the signature about a case that occurs once.

or is not the same as is None

The narrowing shortcut value or default collapses every falsy value, not just None, and an empty string is a legitimate member of str | None:

falsy.py
def site_name(name: str | None) -> str:
    return name or "unnamed"

def site_name_strict(name: str | None) -> str:
    return "unnamed" if name is None else name

print(repr(site_name("")), repr(site_name_strict("")))
$ python falsy.py
'unnamed' ''

The first discarded a caller's deliberate empty name. Same for 0 in int | None and [] in list[str] | None — the values most likely to matter are the falsy ones.

The return type is a union too

-> float | None is a promise that every caller must unwrap. It is the right annotation when absence is a real outcome, and the wrong one when the function could simply raise; returning None for failure pushes a branch onto everybody downstream, and the ones who forget it get an AttributeError far from the cause.

Mixing the spellings in one file

Optional[int] on one line and str | None on the next both work, and both mean what they say, but the reader now has to hold two vocabularies. Pick the modern one and let the auto-fixer convert the rest.

Wrapping up

Cheat sheet

You wantWrite
Either of two typesint | str
May be absentint | None, with None last
Absent by defaultdef f(x: int | None = None) — annotation and default agree
A list, or nothinglist[int] | None
A list with gaps in itlist[int | None]
A name for a recurring unionSensorRef = Sensor | int | None
A runtime check against a unionisinstance(x, int | float) — unparameterized members only
The syntax on 3.7–3.9from __future__ import annotations, annotation positions only
To convert an old codebaseruff check --select UP007,UP045 --fix

The short list

  • A | B is the word "or". Order carries no meaning, chaining is just more members, and X | None is what Optional[X] used to spell.
  • Nothing is enforced at runtime. The union constrains callers; the body still has to handle every member, and forgetting one fails later with an ordinary attribute error.
  • Narrow early. One isinstance and one is None at the top turn a three-member parameter into a single concrete type for the rest of the function.
  • It is a real object. types.UnionType, printable, assignable to an alias, and usable in isinstance — unlike the typing spelling.
  • Bracket placement is semantic. list[int] | None and list[int | None] accept disjoint sets of values.
  • Every member is a branch someone maintains. Three is usually a helpful interface; six is usually a missing conversion at one call site.

Further reading

8/23/2026

MareArts ANPR App: On-Device Plates, MMC Vehicle Info, Team Sync, Web and Desktop Viewers

MareArts ANPR is a license-plate recognition app for iOS and Android, with a web viewer and a desktop viewer on the same account. Plates are read on the phone. Cloud MMC (make, model, colour, type, side, nation), team sync, rules, maps, and stats sit on marearts.com.

MareArts ANPR app live scan with plate box, crop, and 99 percent confidence
Scan — live box, crop, confidence
MareArts ANPR detections list with mosaicked plates and MMC make model colour
Detections — mosaicked plates plus MMC

Scan: on-device ANPR and cloud

Hold the phone at the lane. The app finds the plate, reads the characters, and stores time plus GPS. No extra camera box and no PC at the gate.

  • Single — one capture.
  • Continuous — keep scanning; a duplicate window (default 5 seconds) skips the same plate.
  • Cloud — send the frame for OCR plus MMC vehicle info in one step.

On-device mode keeps detection and OCR on the phone after the models are installed. Cloud mode is optional when you want vehicle identity with the plate.

Camera: 1080p, 720p recommended, or 480p. Preview 60 or 30 fps. Lower resolution is faster and uses less storage. Zoom 1× / 2×, flash, front or rear camera. Landscape scanning keeps boxes and controls upright.

Trial is 10 scans per day without login. A license on the product page removes that cap.

MareArts ANPR detection detail with mosaicked plate and MMC vehicle info
Detail — mosaicked plate, make, model, colour
MareArts ANPR statistics with daily bars, total scans, unique vehicles
Stats — daily activity, unique vehicles

MMC vehicle info (make, model, colour)

MMC here means MareArts cloud vehicle identity, not a third-party brand. For each detection the API can return:

  • Make and model (with confidence)
  • Colour (with a colour chip)
  • Type (car, SUV, van, …)
  • Side — front or rear
  • Nation — country of the plate

The phone list shows a short line such as “Volvo XC60 · black”. The detail card shows all six fields. Cloud scan writes MMC immediately; on-device scans pick it up on sync. The web viewer filters the same fields (make, model, colour, type).

MMC uses the MareArts cloud API and is included with the subscription. Daily vehicle-info usage is shown in Settings.

MareArts ANPR phone list with mosaicked plates and MMC lines
List — Volvo XC60 · black, and the rest
MareArts ANPR vehicle info card with make model colour type side nation
Card — make, model, colour, type, side, nation

Detections, map, and stats

  • List — grouped by day; All / Latest; swipe to set whitelist, blacklist, or unknown.
  • Detail — full frame, plate crop, confidence, first/last seen, GPS, history of that plate.
  • Map — pins and clusters, satellite or road, optional plate labels, search.
  • Stats — today / week / month / year / custom range; unique vehicles; filter by rule status.
  • CSV export — plate, time, GPS, confidences, rule note, plus MMC columns.
MareArts ANPR detection map with GPS clusters and plate labels on satellite view
Map — clusters, plate labels, GPS

Whitelist and blacklist

Rules mark a plate as allowed (green) or blocked (red) on the next scan, with sound and vibration. Notes can sit on the detection card.

  • Search, letter groups, swipe to delete, CSV import and export.
  • Upload a large CSV on marearts.com, then pull the package on the phone (replace or merge).

Team project

One team leader creates a team and shares a password. Members join and contribute detections and rules. Cloud sync is two-way. Auto-sync can run when the app goes to the background.

  • Leader sees every member’s data on the web viewer and can switch member in the dashboard.
  • Desktop viewer for a leader can pull the same team detections.
  • Leave or break team returns a user to solo mode.

Typical use: several guards at a gate, one supervisor on the web viewer without standing at the lane.

Web viewer

After login, My ANPR Data is the browser console for the same account: detections, MMC filters, rule packages, team member switch, export. The phone captures; the browser reviews.

MareArts ANPR web viewer detection list with make model colour columns
Web viewer — detections with MMC columns
MareArts ANPR web viewer filters for vehicle make model color and type
Web viewer — filter by make, model, colour, type

Desktop viewer

MareArts ANPR Desktop is a free companion for Windows, macOS, and Linux. Same login. It is a read-only viewer: it downloads cloud data and does not edit or delete it. Capture and rule changes stay on the phone.

  • Detections with MMC and location
  • Map with clustering
  • Rules (view)
  • Statistics
  • Local image drop — run a still through the MareArts ANPR cloud API
  • One-click sync, optional auto-sync
  • Team leader: all members’ detections, same as the web viewer
MareArts ANPR Desktop viewer detections list with vehicle thumbnails and MMC tags
Desktop viewer — detections and MMC on a large screen

Webhook, plate regions, SDK

  • Webhook — JSON to Discord, Slack, Zapier, Make, or your own URL (plate, time, GPS, confidences, bbox, rule, optional image).
  • Plate region — Universal, Europe+, Korea, North America, China.
  • Thresholds — detection and OCR 60–95% (90% recommended); max plates per frame 1–10.
  • Python ANPR SDK and Road Objects SDK — same license family; offline after activation. See the license page.

How to start

  1. Install MareArts ANPR (search “marearts anpr” on the App Store or Google Play).
  2. Open Scan and use the daily trial.
  3. Subscribe on www.marearts.com/products/anpr. The serial key is emailed to the PayPal address and also appears after login.
  4. Sign in under Settings. Sync fills the web viewer and the desktop viewer.

Serial keys are bound to the PayPal email and cannot be moved. Plans and SDK options stay on the product page.

API and ABI, and the line between them

C · Python · Linking

Two acronyms one letter apart, describing promises made at two different moments. An API is what you agree to when you write the call. An ABI is what your compiled bytes agree to when they meet somebody else's compiled bytes — and unlike the API, breaking it usually produces no error at all.

The clearest way in is to put them side by side. Only the middle word differs: Programming or Binary.

AcronymLevelA promise that sounds like
API
Application Programming Interface
source code "there is a function resize(img, width, height) and it returns an int"
ABI
Application Binary Interface
machine code and memory "width is at byte 0 and is 4 bytes; the first argument arrives in register rdi"

An API is a contract between a programmer and a library. An ABI is a contract between two piles of already-compiled machine code. That is why two languages can call each other at all: Python has no idea what a C header looks like, but if it lays the bytes out the way the ABI says and puts the arguments where the ABI says, the call works.

This post is in five parts. First the distinction and what an ABI actually specifies. Then the three pieces you can see and measure — struct layout, symbol names, argument registers — each with a program you can run. Then the same boundary crossed from Python, including two mistakes that produce wrong answers in total silence. Finally, what it takes to keep an ABI stable once other people depend on it.

Everything was compiled and run before publishing: gcc 11.4.0 on x86-64 Linux, CPython 3.10.12, GNU binutils 2.38. Every output block is copied from a real run.

Contents

  1. Two contracts — the distinction, and why it costs you a rebuild
  2. Struct layout — the part you can print out
  3. Symbols and registers — what the linker sees, and where arguments go
  4. Crossing from Python — where a mismatch stops being theoretical
  5. Keeping an ABI — what breaks what, and how libraries survive it
  6. Reference — tools, cheat sheet, and a one-screen summary

Part 1 · Two contracts

What each one promises

Take an ordinary header. It declares a struct and a function:

the API, as written
struct config {
    int    width;
    int    height;
    double scale;
};

int config_area(const struct config *c);

Everything visible there is API: the names, the types, the order of the parameters, the fact that the function takes a pointer rather than a value. If any of it changes, code that calls it stops compiling — loudly, with a file and a line number.

The ABI is everything the compiler decided underneath that text, and none of it appears in the header: that width begins at byte 0, that scale begins at byte 8 and not byte 12, that the whole struct occupies 16 bytes, that the pointer argument arrives in the register rdi, that the int comes back in eax, and that the symbol is spelled config_area rather than _Z11config_areaPK6config.

Those decisions are just as much a contract, and both sides are bound by them. The difference is what happens when one side changes its mind.

Recompile, relink, neither

The practical consequence of the distinction is what a change costs the people who use your library.

ChangeAPIABIWhat users must do
Fix a bug inside a function body intactintact Nothing. Drop in the new .so.
Add a brand-new function intactintact Nothing, unless they want the new one.
Rename a function brokenbroken Edit their source, rebuild. The compiler tells them.
Reorder two struct fields intactbroken Rebuild — and nothing tells them.
Change a field from int to long mostly intactbroken Rebuild. Again, silently.

The last two rows are the whole reason this topic matters. A broken API is a compiler error; a broken ABI is a program that runs and gives you the wrong number. The rest of this post is mostly about making that second row concrete enough that you can spot it.

What an ABI decides

For a plain C interface on one machine, the list is short and worth memorising:

  1. Struct layout — which byte each field starts at, and how big the whole thing is. Part 2.
  2. Symbol naming — what the function is called in the object file. Part 3.
  3. The calling convention — which register or stack slot each argument travels in, and who cleans up. Part 3.
  4. Return values — which register the result comes back in, which depends on its type.

Sizes and alignments of the basic types sit underneath all four. C only guarantees relationships — that a long is at least as wide as an int — and the ABI picks the actual numbers, which is why long is 8 bytes on Linux and 4 bytes on 64-bit Windows.

Naming

The convention used throughout this post is the x86-64 System V ABI, which is what Linux and macOS use. Windows has a different one, as do 32-bit x86 and the various ARM targets. The concepts transfer exactly; the specific registers do not.

Part 2 · Struct layout

Where the fields actually land

You do not have to guess at any of this. offsetof and sizeof report what the compiler decided, and _Alignof reports the rule it was following.

layout.c — same fields, two orders
struct badly_ordered {
    char   tag;
    double value;
    int    id;
};

struct well_ordered {
    double value;
    int    id;
    char   tag;
};

#define SHOW(S, M)                                                       \
    printf("  %-6s offset %2zu   size %2zu   align %2zu\n", #M,          \
           offsetof(S, M), sizeof(((S *)0)->M), _Alignof(((S *)0)->M))
./layout
struct badly_ordered   sizeof = 24   align 8
  tag    offset  0   size  1   align  1
  value  offset  8   size  8   align  8
  id     offset 16   size  4   align  4

struct well_ordered    sizeof = 16   align 8
  value  offset  0   size  8   align  8
  id     offset  8   size  4   align  4
  tag    offset 12   size  1   align  1

field bytes actually used: 13
padding in badly_ordered : 11
padding in well_ordered  : 3

Alignment and padding

Two rules produce every number in that output.

Each field starts at a multiple of its own alignment. A double has alignment 8, so it may only begin at byte 0, 8, 16 and so on. In badly_ordered, tag occupies byte 0, and value cannot start at byte 1 — the compiler skips to byte 8 and leaves seven bytes of nothing behind.

The struct's own size is rounded up to its largest field alignment. That is what makes arrays work: if sizeof were not a multiple of 8, the second element of an array would put value on a misaligned address. In badly_ordered, id ends at byte 20 and the size rounds up to 24.

The two structs contain identical data — a double, an int and a char, 13 bytes of payload — and one is 50% larger than the other. Sorting fields from widest to narrowest, as well_ordered does, is a reliable way to get most of that back.

Note

Padding bytes have no defined value. This is why memcmp on two structs can report a difference when every field is equal, and why writing a struct straight to a file can leak whatever happened to be on the stack.

Reordering is an ABI change with no API change

Now put the two structs next to each other again and ask what a caller sees. Both have the same three fields with the same names and the same types. Any code that says c.value = 1.5 compiles unchanged against either one. The API is identical.

The bytes are not. Field value lives at offset 8 in one and offset 0 in the other. A library compiled against the first and a caller compiled against the second will agree on every name and disagree on every address — and no tool in the build will say a word, because each half is internally consistent.

Trap

sizeof matching is not evidence that two layouts agree. Two different orderings can easily produce the same total size — you will see exactly that in part 4, where a reordered struct is 16 bytes on both sides and still reads the wrong bytes.

Part 3 · Symbols and registers

Name mangling

A linker does not match calls to functions by reading your source. It matches strings. What string a function gets is part of the ABI, and C and C++ answer that question very differently.

mangling.cpp
int add(int a, int b) { return a + b; }

double add(double a, double b) { return a + b; }

namespace geometry {
int add(int a, int b) { return a + b; }
}  // namespace geometry

extern "C" int add_c(int a, int b) { return a + b; }
g++ -c mangling.cpp -o mangling.o && nm mangling.o
0000000000000018 T _Z3adddd
0000000000000000 T _Z3addii
0000000000000040 T _ZN8geometry3addEii
0000000000000058 T add_c

Three functions are all called add in the source and none of them is called add in the object file. C++ encodes the namespace and the parameter types into the symbol — that encoding is what makes overloading possible, since the linker needs a different string for each version. _Z3addii is add taking two ints; _Z3adddd is the double one.

nm -C undoes it, as does piping any symbol through c++filt:

nm -C mangling.o
0000000000000018 T add(double, double)
0000000000000000 T add(int, int)
0000000000000040 T geometry::add(int, int)
0000000000000058 T add_c

What extern "C" is for

The fourth symbol is the odd one out: add_c, spelled exactly as written. That is the entire effect of extern "C" — it tells a C++ compiler to use the C naming rule (the plain name) and the C calling rule for this function, so that anything outside C++ can find it.

It is not a performance hint and it does not change the function body. It is a promise about the string in the symbol table, which is why every library meant to be called from another language has a wall of it around its public header. And it is why C++ features that need mangling — overloads, templates, namespaces — cannot appear on such a boundary: there is only one add_c available, so there can only be one function of that name.

Look at the exported symbols of a C library and you get the flat list you would expect:

nm -D --defined-only libconfig.so
0000000000001119 T config_area
0000000000001174 T config_describe
00000000000011de T config_offset_scale
0000000000001137 T config_scaled_area
00000000000011cf T config_sizeof

That list, plus the layout of struct config, is the library's entire ABI surface. The T means the symbol is defined here and visible to others; U would mean undefined and needed from somewhere else.

The calling convention

The last piece is where arguments physically go. Three tiny functions and a disassembler answer it directly.

callconv.c
int sum4(int a, int b, int c, int d) { return a + b + c + d; }

double product2(double x, double y) { return x * y; }

int mixed(int a, double x, int b) { return a + b + (int)x; }
gcc -O1 -c callconv.c && objdump -d --no-show-raw-insn callconv.o
0000000000000000 <sum4>:
   0:	endbr64
   4:	add    %esi,%edi
   6:	add    %edx,%edi
   8:	lea    (%rdi,%rcx,1),%eax
   b:	ret

000000000000000c <product2>:
   c:	endbr64
  10:	mulsd  %xmm1,%xmm0
  14:	ret

0000000000000015 <mixed>:
  15:	endbr64
  19:	add    %esi,%edi
  1b:	cvttsd2si %xmm0,%eax
  1f:	add    %edi,%eax
  21:	ret

There is no prologue, no stack, no memory traffic at all. In sum4 the four arguments are already sitting in edi, esi, edx and ecx when the function begins, and the result is left in eax. That order is not something gcc invented; it is written down in the ABI, and every compiler targeting this platform obeys it.

KindArguments 1–6Beyond thatReturn
Integers and pointers rdi rsi rdx rcx r8 r9 pushed on the stack rax
Floats and doubles xmm0xmm7 pushed on the stack xmm0

product2 confirms the second row: mulsd %xmm1,%xmm0 multiplies the two arguments in place and the result is already in the return register.

The detail that surprises people

Look again at mixed(int a, double x, int b). The instruction is add %esi,%edi — so a is in edi and b is in esi, even though b is the third parameter.

The two register sequences are counted independently. Integers take rdi, rsi, … in order among themselves; floats take xmm0, xmm1, … in order among themselves. The double in the middle consumes xmm0 and does not consume an integer slot, so b gets the next integer register rather than the third one.

Why you care

Because it means you cannot work out where an argument goes by counting parameters. Any hand-written declaration that gets a parameter's type wrong — say, declaring a double parameter as int in an FFI binding — does not merely mistranslate that one value. It shifts every argument after it into the wrong register.

Part 4 · Crossing over from Python

The library

Everything so far has been observation. This part is where a mismatch actually costs you, and the most convenient way to build one is a C library and a Python client, because Python has no access to the header and has to be told the layout by hand.

libconfig.c
struct config {
    int    width;
    int    height;
    double scale;
};

int config_area(const struct config *c) {
    return c->width * c->height;
}

const char *config_describe(const struct config *c) {
    static char buf[64];
    snprintf(buf, sizeof buf, "%dx%d @ %.2fx", c->width, c->height, c->scale);
    return buf;
}

/* Self-description, so a caller can verify its mirror instead of trusting it. */
size_t config_sizeof(void) { return sizeof(struct config); }
size_t config_offset_scale(void) { return offsetof(struct config, scale); }

Those last two functions are worth a moment. They export nothing useful to a program — but they let a caller check its assumptions instead of hoping. A library that describes its own layout turns a silent class of bug into an assertion, for the price of two lines.

A mirror that matches

On the Python side, ctypes needs the layout restated. This declaration is a hand-copy of the C struct, and the whole exercise depends on it being right.

client.py
class Config(ctypes.Structure):
    _fields_ = [
        ("width", ctypes.c_int),
        ("height", ctypes.c_int),
        ("scale", ctypes.c_double),
    ]

lib.config_area.argtypes = [ctypes.POINTER(Config)]
lib.config_area.restype = ctypes.c_int

assert ctypes.sizeof(Config) == lib.config_sizeof()
assert Config.scale.offset == lib.config_offset_scale()
python3 client.py — part 1
== 1. matching mirror ==
sizeof        : python 16 | c 16
offset(scale) : python 8 | c 8
area          : 2073600
describe      : 1920x1080 @ 1.50x

Python computed offset 8 for scale by applying the same alignment rules the C compiler applied, independently, from the field types alone. That is not a coincidence — ctypes implements the platform ABI. It is also the reason the next section works the way it does.

A mirror that doesn't

Now the realistic accident: somebody writes the same three fields in a different order. Perhaps they copied from documentation that listed them alphabetically. Every name is right, every type is right.

client.py — the same fields, reordered
class ConfigWrong(ctypes.Structure):
    _fields_ = [
        ("scale", ctypes.c_double),
        ("width", ctypes.c_int),
        ("height", ctypes.c_int),
    ]
python3 client.py — part 2
== 2. reordered mirror ==
sizeof        : python 16 | c 16 -> agrees, and proves nothing
offset(scale) : python 0 | c 8 -> the actual disagreement
area          : 0
describe      : 0x1073217536 @ 0.00x
no exception, no warning, no crash

A 1920×1080 image now has an area of zero, and a height of 1073217536. Those numbers are not random: C read width from bytes 0–3, which in this layout hold the bottom half of the double 1.5, and that half is all zeros. It read height from bytes 4–7, the top half of the same double, which is 0x3FF80000 — the number printed.

Three things about that output deserve to be uncomfortable. There is no exception. There is no warning. And sizeof agrees perfectly on both sides, because reordering these particular fields happens to produce the same total. A size check would have passed this straight through; only the offset check catches it.

Trap

ctypes type-checks the Python side thoroughly and the C side not at all. It will refuse a ConfigWrong where you declared POINTER(Config) — but it has no way to know what the library thinks either of them looks like. Every guarantee stops at the boundary.

The restype trap

The other everyday mistake is forgetting to declare what comes back. ctypes assumes a C int unless told otherwise, and part 3 of the calling convention says an int comes back in rax while a double comes back in xmm0. Two different registers.

python3 client.py — part 3
== 3. missing restype ==
default (int) : 0
c_double      : 3110400.0
expected      : 3110400.0

The first call reads an integer register that the function never wrote to. It printed 0 here and it will print whatever is left over there on your machine; the point is only that it has nothing to do with the answer.

The same failure with a pointer return is worse. Left as the default int, a returned pointer is truncated to its low 32 bits, and dereferencing the result is a segmentation fault that appears to come from inside the C library. Setting argtypes and restype on every function you import is not ceremony — it is the only place the ABI gets written down on the Python side.

What the Python examples actually demonstrate

  • An FFI binding is a hand-copy of an ABI. Nothing checks it against the library, so it drifts the moment the library changes.
  • A matching sizeof proves very little. Compare offsets, field by field, or compare nothing.
  • Have the library describe itself. Two exported functions turned an invisible failure into an assert that fires on line one.

Part 5 · Keeping an ABI stable

What breaks which

Once other people ship binaries built against your library, every change falls into one of these boxes. The dangerous column is the one where the API survives and the ABI does not, because that is the change nobody notices.

Change to a public struct or functionAPIABI
Add a functionokok
Change a function bodyokok
Rename a parameter in the headerokok
Reorder struct fieldsokbroken
Insert a field in the middleokbroken
Widen a field, intlongokbroken
Add a field at the endokbroken if callers allocate it
Change a parameter's typebrokenbroken
Remove or rename a functionbrokenbroken
Change a #define constantbehaviour changesok

"Add a field at the end" is the interesting one. If callers only ever receive a pointer your library allocated, growing the struct is harmless — they never compute its size. If callers declare one on their own stack, they will allocate the old, smaller size, and your library will write past the end of it.

How libraries stay compatible

Hide the struct entirely

The strongest option: callers never see the fields, only a pointer to something they cannot inspect.

opaque handle
/* in the public header */
typedef struct config config_t;        /* declared, never defined */

config_t *config_create(void);
void      config_set_scale(config_t *c, double scale);
int       config_area(const config_t *c);
void      config_destroy(config_t *c);

The layout is now a private implementation detail — it can change in any release, because no caller ever computed an offset into it. The cost is a function call per field and an allocator round trip. This is how most C libraries with long support windows do it.

Put the size in the struct

When callers must allocate, have them declare how big their version is, and check.

versioned struct
struct config {
    size_t struct_size;    /* caller sets this to sizeof(struct config) */
    int    width;
    int    height;
    double scale;
    /* new fields may only be appended below */
};

int config_area(const struct config *c) {
    if (c->struct_size < offsetof(struct config, scale)) return -1;
    ...
}

The library can now tell an old caller from a new one at runtime and read only the fields that caller actually has. The rule is absolute, though: fields may only ever be appended, and existing ones may never move or change width.

Reserve space up front

A cruder version of the same idea — char reserved[32]; at the end of the struct, spent later on new fields without changing the size. It works, it is ugly, and it is everywhere.

Version the symbol, not the struct

When a function genuinely has to change shape, keep the old one and add a new name: config_area stays, config_area2 appears. Old binaries keep finding the old symbol. The C standard library has done this for decades, which is why symbols with version suffixes turn up in nm output on any mature library.

soname, and how the loader picks a file

Shared libraries carry a name inside them, and it is that name — not the filename — that gets recorded in every program linked against them.

building with a soname
gcc -shared -fPIC -Wl,-soname,libconfig.so.1 -o libconfig.so.1.0.0 libconfig.c
readelf -d libconfig.so.1.0.0 | head -6
readelf -d libconfig.so.1.0.0
Dynamic section at offset 0x2e10 contains 25 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000e (SONAME)             Library soname: [libconfig.so.1]
 0x000000000000000c (INIT)               0x1000

The convention that falls out of this is the familiar three-level filename. The file is libconfig.so.1.0.0; the soname is libconfig.so.1, and that is what programs record; the bare libconfig.so is a symlink that only the linker uses at build time.

The middle number is the promise. Bump the patch level for a bug fix, bump the minor for added functions, and bump the soname to .so.2 when the ABI breaks. Because the soname is part of the filename, version 1 and version 2 can sit in the same directory and old programs keep loading the one they were built against. That is the whole mechanism by which a Linux system runs binaries compiled years apart.

Part 6 · Reference

Tools worth knowing

nm -D --defined-only lib.soExported symbols — the callable ABI surface
nm -C, c++filtDemangle C++ symbol names
readelf -d lib.sosoname and the list of libraries it needs
readelf -Ws lib.soFull symbol table with sizes and visibility
objdump -d --no-show-raw-insn f.oDisassembly — see the calling convention directly
ldd ./programWhich libraries actually get loaded, and from where
gcc -fdump-lang-raw, paholeStruct layout and padding, reported by the toolchain
offsetof, _Alignof, _Static_assertAssert the layout from inside the program itself

Cheat sheet

The distinction

APISource-level contract. Broken → compiler error, with a line number.
ABIBinary-level contract. Broken → wrong answers, usually with no message at all.

x86-64 System V, in four lines

Integer / pointer argumentsrdi rsi rdx rcx r8 r9, then the stack
Float / double argumentsxmm0xmm7, counted separately
Return valuerax, or xmm0 for floating point
Struct field offsetsEach field aligned to its own width; total rounded up to the largest

Writing an FFI binding

Set argtypes and restypeOn every single function. The defaults are wrong for pointers and doubles.
Assert sizeof and offsetsSizes can match while layouts differ.
Export a self-descriptionTwo functions returning sizeof and an offsetof pay for themselves.
Prefer opaque pointersA layout nobody can see is a layout nobody can get wrong.

Wrapping up

What to take away

The mnemonic in the first table is worth keeping — Programming versus Binary, one contract for people writing code and one for code that has already been written. But the reason to care is narrower than that.

The short list

  • An API break is loud; an ABI break is silent. The compiler is on your side for one of them and completely absent for the other.
  • Field order is part of your public interface. It does not look like it in the header, and swapping two lines is a breaking change.
  • Every hand-written binding is a copy of a layout, so make it checkable. Export sizeof and a couple of offsets, assert them at import time, and the entire category of bug in part 4 turns into a failed assertion.

The stability techniques in part 5 only matter once someone else ships a binary against your code. Until then the ABI is a private matter and you can rearrange whatever you like. The day that stops being true is the day the opaque pointer starts looking cheap.

Further reading

Appendix · The complete programs

Building and running

Five short files produced every output on this page. They are reproduced in full below, so copying the snippets into one directory is enough to reproduce all of it.

build and run everything
gcc -std=c11 -Wall -Wextra -o layout layout.c && ./layout

gcc -std=c11 -Wall -Wextra -shared -fPIC -o libconfig.so libconfig.c
python3 client.py

g++ -c mangling.cpp -o mangling.o && nm mangling.o && nm -C mangling.o

gcc -O1 -c callconv.c -o callconv.o
objdump -d --no-show-raw-insn callconv.o

gcc -shared -fPIC -Wl,-soname,libconfig.so.1 -o libconfig.so.1.0.0 libconfig.c
readelf -d libconfig.so.1.0.0 | head -6

Verified on gcc 11.4.0 and GNU binutils 2.38 (Ubuntu 22.04, x86-64) with CPython 3.10.12. The one value that is not reproducible is the integer read in part 3 of client.py, which is whatever the return register happened to contain.

layout.c 43 lines

layout.c
#include <stddef.h>
#include <stdio.h>

/* Same three fields, same API, two different binary layouts. */

struct badly_ordered {
    char   tag;
    double value;
    int    id;
};

struct well_ordered {
    double value;
    int    id;
    char   tag;
};

#define SHOW(S, M)                                                       \
    printf("  %-6s offset %2zu   size %2zu   align %2zu\n", #M,          \
           offsetof(S, M), sizeof(((S *)0)->M), _Alignof(((S *)0)->M))

int main(void) {
    size_t payload = sizeof(double) + sizeof(int) + sizeof(char);

    printf("struct badly_ordered   sizeof = %2zu   align %zu\n",
           sizeof(struct badly_ordered), _Alignof(struct badly_ordered));
    SHOW(struct badly_ordered, tag);
    SHOW(struct badly_ordered, value);
    SHOW(struct badly_ordered, id);

    printf("\nstruct well_ordered    sizeof = %2zu   align %zu\n",
           sizeof(struct well_ordered), _Alignof(struct well_ordered));
    SHOW(struct well_ordered, value);
    SHOW(struct well_ordered, id);
    SHOW(struct well_ordered, tag);

    printf("\nfield bytes actually used: %zu\n", payload);
    printf("padding in badly_ordered : %zu\n",
           sizeof(struct badly_ordered) - payload);
    printf("padding in well_ordered  : %zu\n",
           sizeof(struct well_ordered) - payload);
    return 0;
}

callconv.c 9 lines

callconv.c
/* Where do arguments actually go? Build and look:
     gcc -O1 -c callconv.c -o callconv.o
     objdump -d --no-show-raw-insn callconv.o */

int sum4(int a, int b, int c, int d) { return a + b + c + d; }

double product2(double x, double y) { return x * y; }

int mixed(int a, double x, int b) { return a + b + (int)x; }

mangling.cpp 12 lines

mangling.cpp
/* What a linker actually sees. Build with:
     g++ -c mangling.cpp -o mangling.o && nm mangling.o */

int add(int a, int b) { return a + b; }

double add(double a, double b) { return a + b; }

namespace geometry {
int add(int a, int b) { return a + b; }
}  // namespace geometry

extern "C" int add_c(int a, int b) { return a + b; }

libconfig.c 29 lines

libconfig.c
#include <stddef.h>
#include <stdio.h>

/* Everything a caller in another language has to agree with lives here:
   the layout of struct config, and the exported symbols below. */

struct config {
    int    width;
    int    height;
    double scale;
};

int config_area(const struct config *c) {
    return c->width * c->height;
}

double config_scaled_area(const struct config *c) {
    return c->width * c->height * c->scale;
}

const char *config_describe(const struct config *c) {
    static char buf[64];
    snprintf(buf, sizeof buf, "%dx%d @ %.2fx", c->width, c->height, c->scale);
    return buf;
}

/* Self-description, so a caller can verify its mirror instead of trusting it. */
size_t config_sizeof(void) { return sizeof(struct config); }
size_t config_offset_scale(void) { return offsetof(struct config, scale); }

client.py 76 lines

client.py
"""Three ways to talk to libconfig.so: one right, two wrong."""

import ctypes

lib = ctypes.CDLL("./libconfig.so")
lib.config_sizeof.restype = ctypes.c_size_t
lib.config_offset_scale.restype = ctypes.c_size_t

# ---- 1. a mirror that matches the C struct -------------------------------

class Config(ctypes.Structure):
    _fields_ = [
        ("width", ctypes.c_int),
        ("height", ctypes.c_int),
        ("scale", ctypes.c_double),
    ]

lib.config_area.argtypes = [ctypes.POINTER(Config)]
lib.config_area.restype = ctypes.c_int
lib.config_describe.argtypes = [ctypes.POINTER(Config)]
lib.config_describe.restype = ctypes.c_char_p

print("== 1. matching mirror ==")
print("sizeof        : python", ctypes.sizeof(Config), "| c", lib.config_sizeof())
print("offset(scale) : python", Config.scale.offset, "| c", lib.config_offset_scale())
assert ctypes.sizeof(Config) == lib.config_sizeof()
assert Config.scale.offset == lib.config_offset_scale()

cfg = Config(width=1920, height=1080, scale=1.5)
print("area          :", lib.config_area(ctypes.byref(cfg)))
print("describe      :", lib.config_describe(ctypes.byref(cfg)).decode())

# ---- 2. the same fields, declared in a different order -------------------
# A second handle, as if a different program had written its own mirror.

class ConfigWrong(ctypes.Structure):
    _fields_ = [
        ("scale", ctypes.c_double),
        ("width", ctypes.c_int),
        ("height", ctypes.c_int),
    ]

other = ctypes.CDLL("./libconfig.so")
other.config_area.argtypes = [ctypes.POINTER(ConfigWrong)]
other.config_area.restype = ctypes.c_int
other.config_describe.argtypes = [ctypes.POINTER(ConfigWrong)]
other.config_describe.restype = ctypes.c_char_p
other.config_offset_scale.restype = ctypes.c_size_t

print("\n== 2. reordered mirror ==")
print("sizeof        : python", ctypes.sizeof(ConfigWrong), "| c", lib.config_sizeof(),
      "-> agrees, and proves nothing")
print("offset(scale) : python", ConfigWrong.scale.offset, "| c",
      other.config_offset_scale(), "-> the actual disagreement")

bad = ConfigWrong(scale=1.5, width=1920, height=1080)
print("area          :", other.config_area(ctypes.byref(bad)))
print("describe      :", other.config_describe(ctypes.byref(bad)).decode())
print("no exception, no warning, no crash")

# ---- 3. a function called without declaring its return type --------------

print("\n== 3. missing restype ==")
lib.config_scaled_area.argtypes = [ctypes.POINTER(Config)]
print("default (int) :", lib.config_scaled_area(ctypes.byref(cfg)))

lib.config_scaled_area.restype = ctypes.c_double
print("c_double      :", lib.config_scaled_area(ctypes.byref(cfg)))
print("expected      :", 1920 * 1080 * 1.5)