Python proves excellent for rapid prototyping and its ecosystem is
extensive; however, when real speed is required, compiled languages offer
significant advantages. Python provides excellent interoperability with
C, and Zig can share the same Application Binary Interface (ABI) as C,
which allows establishing this connection.
This compatibility enables writing performance-critical parts in Zig and
exposing them as if they were normal Python functions. Zig offers
features that C doesn't provide natively: more explicit memory
management, compile-time execution, and error handling that forces
consideration of all failure cases.
It's important to clarify from the outset: what's presented here is not
the only way to connect Python with Zig. There are different approaches,
each with advantages and disadvantages. This article shows a
straightforward method using C's FFI and Python's ctypes library, which
has the advantage of being clear and requiring no additional
dependencies. What matters is understanding the concept; once understood,
you can apply the method with other variants and to different problems.
A simple HTTP client will be built to illustrate the process. It's not
trivial. There are several steps and details that require attention. But
following this same procedure you can integrate virtually any Zig
functionality into Python. We'll see how to create Zig functions, wrap
them so C (and Python) can understand them, and handle details like
memory management, character strings, and errors that transit between
languages.
The request Function in Zig
The straightforward implementation is presented. A function is required
that performs HTTP GET requests and returns the response in a format that
C (and thus Python) can understand. The function signature is revealing:
fn request(allocator: std.mem.Allocator, url: [:0]const u8) ![:0]u8
Two parameters: first allocator, which represents Zig's way of handling
memory allocation. There are no hidden malloc() calls—everything is
explicit. Second, url, which is a null-terminated pointer
([:0]const u8). Zig has its own way of representing this, but it's
equivalent to const char* in C.
The return type ![:0]u8 deserves attention. The initial ! indicates
this can fail. You can get a pointer with the HTTP response, or you can
get an error. The [:0]u8 represents the successful result: bytes with a
zero at the end, exactly what C expects.
The implementation uses Zig 0.15's HTTP API and an ArrayListUnmanaged
to accumulate the response byte by byte:
const std = @import("std");
fn request(allocator: std.mem.Allocator, url: [:0]const u8) ![:0]u8 {
const len = std.mem.len(url);
const url_slice = url[0..len];
const uri = try std.Uri.parse(url_slice);
var client = std.http.Client{ .allocator = allocator };
defer client.deinit();
var req = try client.request(.GET, uri, .{});
defer req.deinit();
try req.sendBodiless();
var redirect_buffer: [4096]u8 = undefined;
var response = try req.receiveHead(&redirect_buffer);
var list = std.ArrayListUnmanaged(u8){};
errdefer list.deinit(allocator);
var reader = response.reader(&.{});
try reader.appendRemainingUnlimited(allocator, &list);
try list.append(allocator, 0);
const owned = try list.toOwnedSlice(allocator);
return @ptrCast(owned.ptr);
}
This implementation avoids unnecessary data copies. Note that it accepts
[:0]const u8 directly—the same format that strings come in from C. The
process is:
- Converts the pointer to a normal slice to interpret the URL
- URL parsing:
std.Uri.parse - Creates the HTTP client with the provided allocator
- Constructs a GET request with
client.request - To the server:
sendBodiless()sends it without a body - Response:
receiveHead()reads the headers (with space for redirects) - Accumulating data: Reads the entire body with
ArrayListUnmanaged—efficient because it grows as needed - The null terminator: Adds a zero at the end (C requirement)
- Returns a pointer to the result
A notable aspect of Zig: defer and errdefer. The first indicates
"when exiting this function, regardless of how, clean this up". The
second indicates "if an error occurs, clean this up". Each try
represents a point where something can fail; if it fails, the error
propagates immediately. It certainly looks like a lot of code, but it's
clear what can fail and where.
Functionality can be verified with Zig's testing system:
test "Request" {
const allocator = std.testing.allocator;
const url = "http://localhost";
const response = try request(allocator, url.ptr);
defer {
const len = std.mem.len(response) + 1;
allocator.free(response[0..len]);
}
try std.testing.expect(std.mem.len(response) > 0);
}
Observe how memory is freed: first the length is calculated (including
the final zero), then it's converted to a slice, and finally passed to
allocator.free().
Saving this in request.zig allows execution:
$ zig test request.zig
All 1 tests passed.
Note: an HTTP server running on localhost is required for this test to
work. The testing.allocator is special; it detects when memory isn't
freed. It's very useful for catching memory leaks early.
The Wrappers: Speaking C's Language
The part where Zig is made to speak C is now presented. Zig functions are
powerful, but Python cannot call them directly. A translator is
required—wrapper functions that C (and thus Python) can understand.
In this case request() already returns [:0]u8, which is exactly what
C expects. However, some adjustments are needed:
- Memory management: Provide Python an explicit way to free what's allocated
- Errors: Convert Zig's error unions to something C understands (basically, in this context NULL means "it failed")
The signatures turn out like this:
export fn request_wrapper(url: [:0]const u8) ?[:0]u8
export fn request_deallocate(result: [:0]u8) void
[:0]const u8 is a null-terminated pointer to bytes (equivalent to
const char*). The ? before the return type means "optional"—it can be
a valid pointer or null. The export keyword instructs the compiler to
generate symbols that C can link.
The implementation of request_wrapper:
export fn request_wrapper(url: [:0]const u8) ?[:0]u8 {
const allocator = std.heap.page_allocator;
return request(allocator, url) catch return null;
}
Observe how straightforward the approach is. Since request() already
accepts and returns the correct format, complex conversions aren't
required:
- Zero extra conversions:
request()works with[:0]const u8directly - Zero copies: Direct call, without intermediate buffers
- Single allocation: The one
request()performs internally - Simple errors:
catch return nullconverts any error to NULL
The function to free memory is critical:
export fn request_deallocate(result: [:0]u8) void {
const allocator = std.heap.page_allocator;
const len = std.mem.len(result) + 1;
allocator.free(result[0..len]);
}
It must use the same page_allocator used for allocation (this is
important—you cannot mix allocators). It calculates the total length
(including the zero), converts the pointer to a slice, and finally frees.
This provides a means and control over when to free memory, which is
essential when crossing language boundaries.
A test for the wrappers is shown:
test "Wrappers" {
const url = "http://localhost";
const body = request_wrapper(url.ptr);
try std.testing.expect(std.mem.len(body.?) > 0);
request_deallocate(body.?);
}
If everything works correctly:
$ zig test request.zig
All 2 tests passed.
Testing from C First
Before integrating with Python, it's advisable to verify everything works
from C. It's simpler to debug here than when three layers are involved. A
header that C understands is created:
#ifndef _REQUEST_H
#define _REQUEST_H 0
char *request_wrapper(const char *url);
void request_deallocate(char *content);
#endif // _REQUEST_H
Simple: Zig's [:0]const u8 translates to const char* in C, [:0]u8
translates to char*. Zig's optional types disappear. In C only the
pointer exists and you must check NULL manually.
A test program in C:
#include <stdio.h>
#include <stdlib.h>
#include "request.h"
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s URL\n", argv[0]);
exit(EXIT_FAILURE);
}
const char *url = argv[1];
char *content = request_wrapper(url);
if (!content) {
printf("Failed\n");
exit(EXIT_FAILURE);
}
printf("%s\n", content);
request_deallocate(content);
return 0;
}
The classic C pattern: request_wrapper() is called, verification that
it's not NULL, the result is used, and then request_deallocate() is
called to free memory. If this last step is omitted, there will be memory
leaks.
Compile and run:
$ zig build-lib -dynamic request.zig
$ gcc example.c -L. -lrequest -o example
$ export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
$ ./example http://localhost
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
Zig generates a shared library (.so on Linux, .dylib on macOS, .dll
on Windows). The -dynamic flag indicates that a shared library is
desired rather than static. Then gcc links the C program against the
newly created Zig library.
Finally: Python
Python integration is now presented. The Zig library will be wrapped to
appear as any Python module. ctypes is used, which comes included with
Python and allows loading shared libraries and calling their functions.
The challenge lies in correctly declaring types and not forgetting to
free memory.
A Request class that encapsulates all the complexity:
import ctypes
class Request:
def __init__(self):
self.lib = ctypes.CDLL("./librequest.so")
self.lib.request_wrapper.argtypes = [ctypes.c_char_p]
self.lib.request_wrapper.restype = ctypes.POINTER(ctypes.c_char)
def get(self, url: str) -> str:
result = self.lib.request_wrapper(url.encode())
if not result:
raise RuntimeError("Request failed")
i = 0
while result[i] != b'\0':
i += 1
content = result[:i].decode()
self.lib.request_deallocate(result)
return content
Analysis of each part:
In __init__: The library is loaded with CDLL (for functions that
follow C calling conventions). Then the signatures are declared:
-
argtypes = [ctypes.c_char_p]: the function expects a C string -
restype = ctypes.POINTER(ctypes.c_char): returns a pointer to characters
In get: The entire cycle is handled:
- The Python string is converted to bytes (
encode())—ctypes knows how to pass this as a C string - The Zig function is called and NULL is checked
- Manual search for where the string ends (the
\0byte) - The bytes are extracted and decoded to a Python string
- Critical:
request_deallocate()is called to free memory
The payoff—using this from Python:
import request
req = request.Request()
body = req.get("http://localhost")
print(body)
All the FFI complexity is encapsulated. For whoever uses it, it's simply
another Python library.
Conclusion
Python has been connected with Zig using C as a bridge. It's not magical,
and it's definitely not trivial—there are several steps, types to
convert, memory to handle carefully. But once the process is understood,
it's replicable. The same steps followed here apply to any function you
wish to expose: image processing, ML algorithms, binary protocol
handling, etc.
It's worth remembering: this is one path, not the path. Other ways exist
to make Python and Zig communicate, each with its trade-offs. This one
has the advantage of being relatively straightforward and not depending
on external dependencies.
If you wish to extend this example, you could:
- Add more HTTP methods (POST, PUT, DELETE)—the pattern is the same
- Expose configuration options (timeouts, headers, auth)
- Integrate computationally intensive algorithms written in Zig
- Wrap Zig libraries that interact with the operating system
Key aspects to remember:
- Memory is your responsibility: When crossing between languages, you must know who allocated what and who should free it. Zig forces you to be explicit, which helps avoid leaks, but also requires discipline.
- Types matter: Each language represents strings and arrays in its own way. Conversions must be exact or everything fails.
- Errors in translation: Zig's errors aren't C's errors. You must translate between systems—here NULL was used, but other approaches exist.
- Test in layers: First Zig alone, then from C, finally from Python. This way you identify where the problem is when something fails.
This can be applied with HTTP, but also with any other functionality. Zig
is maturing, and it's likely we'll see more libraries that combine the
best of both worlds: Python's expressiveness with Zig's speed and
low-level control.
Note: This article has originally been written in
Spanish.
Top comments (0)