Python’s struct and ctypes modules let you peek under the hood and manipulate raw memory, which is usually something you only do in C.

Let’s say you have a network packet or a file format defined by a specific byte layout, and you need to read or write it from Python. For example, a common C struct might look like this:

struct Header {
    uint16_t magic;
    uint32_t length;
    char type[8];
};

Here’s how you’d handle that in Python using struct:

import struct

# The format string: '<' for little-endian, 'H' for uint16_t, 'I' for uint32_t, '8s' for 8-byte string
format_string = '<H I 8s'
packed_data = struct.pack(format_string, 0x1234, 1024, b'mytype')
print(f"Packed data: {packed_data}")

unpacked_data = struct.unpack(format_string, packed_data)
print(f"Unpacked data: {unpacked_data}")

The output shows the raw bytes and then how they’re interpreted back into Python types. struct.pack takes Python values and converts them into a byte string according to the format. struct.unpack does the reverse. The format string is key: < for little-endian, > for big-endian, ! for network byte order (big-endian), = for native byte order. Then come the type codes: b (signed char), B (unsigned char), h (short), H (unsigned short), i (int), I (unsigned int), l (long), L (unsigned long), q (long long), Q (unsigned long long), f (float), d (double), s (char array), p (char pointer), P (void pointer).

ctypes takes this a step further by allowing you to define C-compatible data types and even call C functions. You can create instances of these types and directly access their memory.

from ctypes import Structure, c_uint16, c_uint32, c_char

class Header(Structure):
    _pack_ = 1  # Align fields tightly
    _fields_ = [
        ("magic", c_uint16),
        ("length", c_uint32),
        ("type", c_char * 8)
    ]

# Create an instance
header_instance = Header()
header_instance.magic = 0x1234
header_instance.length = 1024
header_instance.type = b'mytype'

# Accessing the underlying bytes (similar to struct.pack)
packed_bytes = bytes(header_instance)
print(f"ctypes packed bytes: {packed_bytes}")

# Creating from bytes (similar to struct.unpack)
new_header = Header.from_buffer_copy(packed_bytes)
print(f"ctypes unpacked: magic={new_header.magic}, length={new_header.length}, type={new_header.type}")

Notice _pack_ = 1. This is crucial for matching C struct layouts exactly, as it disables default padding that C compilers might add. _fields_ defines the members, their types (using ctypes equivalents like c_uint16), and their names. c_char * 8 creates a C-style array of 8 characters. from_buffer_copy creates a new instance by copying data from a buffer, ensuring you don’t accidentally modify the original C data if you were interacting with a C library.

The real power of ctypes comes when you need to interact with shared libraries. You can load a .dll (Windows) or .so (Linux/macOS) file and call its functions directly.

import ctypes
import os

# Example: Using the standard C library's `strlen` function
# On Linux/macOS, it's typically 'libc.so.6' or 'libSystem.dylib'
# On Windows, it's 'msvcrt.dll'
try:
    if os.name == 'nt':
        libc = ctypes.CDLL('msvcrt.dll')
    else:
        libc = ctypes.CDLL('libc.so.6') # Or ctypes.CDLL('libSystem.dylib') on macOS
except OSError:
    print("Could not load C library. Skipping C function call example.")
    libc = None

if libc:
    # Define argument types and return type for strlen
    libc.strlen.argtypes = [ctypes.c_char_p]
    libc.strlen.restype = ctypes.c_size_t

    my_string = b"hello world"
    length = libc.strlen(my_string)
    print(f"Length of '{my_string.decode()}' using libc.strlen: {length}")

Here, we load the C standard library, define the expected argument types (ctypes.c_char_p for a C string) and return type (ctypes.c_size_t for the size) of strlen, and then call it with a Python byte string. This bypasses Python’s string handling entirely for that specific operation.

When you use ctypes to create structures that are meant to be passed to or returned from C functions, you often need to ensure that the structure’s memory layout precisely matches the C compiler’s expectations. This means getting the _pack_ directive correct and using the right ctypes types. For instance, ctypes.c_int might map to int on one platform and long on another, so explicitly using c_int32 or c_uint64 is often safer for cross-platform compatibility or when interfacing with specific C APIs.

The most surprising thing is how ctypes can be used to create C-like objects in memory that Python itself can then interpret and manipulate, effectively letting you build up complex data structures that can be passed to C functions without serialization or intermediate steps. It’s not just about calling C functions; it’s about bridging the fundamental memory representation gap between Python objects and C data types.

The next step is understanding how to handle pointers and memory allocation when interacting with C libraries, which involves ctypes.POINTER and functions like ctypes.cast.

Want structured learning?

Take the full Python course →