Jaime López

Data Science Systems Developer

Using Zig Functions from Python

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:

  1. Converts the pointer to a normal slice to interpret the URL
  2. URL parsing: std.Uri.parse
  3. Creates the HTTP client with the provided allocator
  4. Constructs a GET request with client.request
  5. To the server: sendBodiless() sends it without a body
  6. Response: receiveHead() reads the headers (with space for redirects)
  7. Accumulating data: Reads the entire body with ArrayListUnmanaged—efficient because it grows as needed
  8. The null terminator: Adds a zero at the end (C requirement)
  9. 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:

  1. Memory management: Provide Python an explicit way to free what's allocated
  2. 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:

  1. Zero extra conversions: request() works with [:0]const u8 directly
  2. Zero copies: Direct call, without intermediate buffers
  3. Single allocation: The one request() performs internally
  4. Simple errors: catch return null converts 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:

  1. The Python string is converted to bytes (encode())—ctypes knows how to pass this as a C string
  2. The Zig function is called and NULL is checked
  3. Manual search for where the string ends (the \0 byte)
  4. The bytes are extracted and decoded to a Python string
  5. 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:

  1. 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.
  2. Types matter: Each language represents strings and arrays in its own way. Conversions must be exact or everything fails.
  3. Errors in translation: Zig's errors aren't C's errors. You must translate between systems—here NULL was used, but other approaches exist.
  4. 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.

Resources

Here is the link to a library for integration between Zig and Python:

Note: This article has originally been written in Spanish.