ctypes and FFI
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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
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, ormemoryviewover aStructure. - Prefer a wheel from PyPI over a handwritten
CDLL. - Prefer
subprocessplus 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
- In
c_int_size.py, settable.value = 3and print it again. - In
ticket_struct.py, add astatusfield asc_int(0open,1paid) and print it. - Rewrite Case 2’s print using the dataclass from Case 3 — no ctypes.