ctypes and FFI

Updated

September 8, 2026

ctypes and FFI

Avoid FFI unless you must. Python’s standard types, plus a subprocess or a well-tested package, cover almost all desk work. ctypes talks to C in-process. That is a sharp edge: crashes are not except Exception.

Mental model

FFI (foreign function interface) means calling code compiled for another language, usually C, from Python. ctypes ships with Python. You describe C types (c_int, c_char_p) and call a shared library.

ctypes.sizeof reports the C size of a type. ctypes.c_int is a mutable box around a C int. On common platforms that size is 4.

Calling libc (strlen, and friends) is not portable as a copy-paste: the library name is libc.so.6 on many Linux systems, libSystem on macOS, and something else on Windows. This book does not make that the default example.

If you need C, prefer a maintained package with wheels, or a small extension you build on purpose — not ad-hoc ctypes in a ticket handler.

Worked examples

Case 1: c_int and sizeof (no extra C files)

Save as c_int_size.py. This runs anywhere ctypes runs.

# c_int_size.py
import ctypes


def main() -> None:
    table = ctypes.c_int(12)
    print(table.value)
    print(ctypes.sizeof(table))


if __name__ == "__main__":
    main()

Run:

uv run python c_int_size.py

Output:

12
4

table.value is the Python int. Changing .value mutates the box. A plain int is enough until a C API demands this object.

Case 2: A tiny C-like struct

Save as ticket_struct.py. _fields_ is the C layout. Still no .c file.

# ticket_struct.py
import ctypes


class Ticket(ctypes.Structure):
    _fields_ = [
        ("id", ctypes.c_int),
        ("table", ctypes.c_int),
    ]


def main() -> None:
    ticket = Ticket(7, 12)
    print(ticket.id, ticket.table)
    print(ctypes.sizeof(ticket))


if __name__ == "__main__":
    main()

Run:

uv run python ticket_struct.py

Output:

7 12
8

Two c_int fields, 4 + 4, no padding on this layout. Do not depend on padding guesses when you talk to a real C header — match the header.

Case 3: The Python you wanted instead

Save as ticket_python.py. Same data, no FFI.

# ticket_python.py
from dataclasses import dataclass


@dataclass
class Ticket:
    id: int
    table: int


def main() -> None:
    ticket = Ticket(7, 12)
    print(ticket.id, ticket.table)


if __name__ == "__main__":
    main()

Run:

uv run python ticket_python.py

Output:

7 12

This is the default. Case 2 exists so you recognize ctypes when a library forces it.

The trap

Loading libc “because we can” for a job len already does.

Save as skip_libc.py. We do not call CDLL. We print why the boring path wins.

# skip_libc.py
def main() -> None:
    text = "desk"
    print(len(text))
    print("no libc")


if __name__ == "__main__":
    main()

Run:

uv run python skip_libc.py

Output:

4
no libc

A strlen call through ctypes.CDLL("libc.so.6") may work on your Linux box and fail on the next one. len is Unicode-aware in Python; strlen is bytes and a terminator. They are not the same function.

If you truly must call libc, isolate it behind one function, pass bytes, and test on every OS you ship. That is a last resort, not a style.

The boring rule

  • Do not use ctypes for business logic.
  • Prefer a dataclass, array, or memoryview over a Structure.
  • Prefer a wheel from PyPI over a handwritten CDLL.
  • Prefer subprocess plus a CLI over in-process C if the boundary is already a process.
  • Never let FFI exceptions look like Python bugs: wrap the call, validate pointers, and keep the surface tiny.

Try this

  1. In c_int_size.py, set table.value = 3 and print it again.
  2. In ticket_struct.py, add a status field as c_int (0 open, 1 paid) and print it.
  3. Rewrite Case 2’s print using the dataclass from Case 3 — no ctypes.