Using Zig Functions from Python

Published: (February 17, 2026 at 12:17 AM EST)
7 min read
Source: Dev.to

Source: Dev.to

Overview

This document shows how to write a small HTTP client in Zig, expose it to C, and then call it from Python.
The focus is on:

  • Using Zig’s [:0]const u8 (null‑terminated strings) directly.
  • Avoiding unnecessary copies.
  • Providing a simple C wrapper that can be linked from Python (or any other language).

Zig Implementation

const std = @import("std");

/// Perform an HTTP GET request and return a null‑terminated byte slice.
///
/// * `allocator` – Zig allocator used for all temporary allocations.  
/// * `url`       – Null‑terminated UTF‑8 string (`[:0]const u8`).
///
/// Returns `![:0]u8` – on success a pointer to a C‑compatible string,
/// on error an error union.
fn request(allocator: std.mem.Allocator, url: [:0]const u8) ![:0]u8 {
    // Strip the trailing NUL to get a normal slice for parsing.
    const len = std.mem.len(url);
    const url_slice = url[0..len];
    const uri = try std.Uri.parse(url_slice);

    // Create an HTTP client that uses the supplied allocator.
    var client = std.http.Client{ .allocator = allocator };
    defer client.deinit();

    // Build a GET request.
    var req = try client.request(.GET, uri, .{});
    defer req.deinit();

    // Send the request without a body.
    try req.sendBodiless();

    // Buffer for possible redirects (up to 4 KB).
    var redirect_buffer: [4096]u8 = undefined;
    var response = try req.receiveHead(&redirect_buffer);

    // Collect the response body into an unmanaged ArrayList.
    var list = std.ArrayListUnmanaged(u8){};
    errdefer list.deinit(allocator);

    var reader = response.reader(&.{});
    try reader.appendRemainingUnlimited(allocator, &list);

    // Append a NUL terminator – required for C strings.
    try list.append(allocator, 0);

    // Convert the list into an owned slice and cast to a C pointer.
    const owned = try list.toOwnedSlice(allocator);
    return @ptrCast(owned.ptr);
}

Key Points

StepWhat Happens
URL handling[:0]const u8 is converted to a normal slice for std.Uri.parse.
Client creationstd.http.Client is instantiated with the caller‑provided allocator.
Requestclient.request(.GET, uri, .{}) builds a GET request.
Sendreq.sendBodiless() sends the request (no body).
Receivereq.receiveHead(&redirect_buffer) reads the response headers.
Body collectionArrayListUnmanaged(u8) grows as needed; appendRemainingUnlimited reads the full body.
NUL terminatorlist.append(allocator, 0) adds the required C‑style terminator.
ReturnThe owned slice is cast to [:0]u8, which is a char*‑compatible pointer.

Zig Unit Test

test "Request" {
    const allocator = std.testing.allocator;
    const url = "http://localhost";
    // `url.ptr` is a `[:0]const u8` (null‑terminated) pointer.
    const response = try request(allocator, url.ptr);
    defer {
        // Include the NUL terminator when freeing.
        const len = std.mem.len(response) + 1;
        allocator.free(response[0..len]);
    }
    try std.testing.expect(std.mem.len(response) > 0);
}

Running the test:

$ zig test request.zig
All 1 tests passed.

Note: An HTTP server must be listening on localhost for the test to succeed.

C Wrapper

Zig can export functions that are callable from C.
The exported signatures use the same null‑terminated types, but the optional return (?[:0]u8) is turned into a plain pointer that may be NULL.

/// Public wrapper that returns `NULL` on any error.
export fn request_wrapper(url: [:0]const u8) ?[:0]u8 {
    const allocator = std.heap.page_allocator;
    return request(allocator, url) catch return null;
}

/// Free the memory allocated by `request_wrapper`.
export fn request_deallocate(result: [:0]u8) void {
    const allocator = std.heap.page_allocator;
    const len = std.mem.len(result) + 1; // include NUL
    allocator.free(result[0..len]);
}

Wrapper Test

test "Wrappers" {
    const url = "http://localhost";
    const body = request_wrapper(url.ptr);
    try std.testing.expect(std.mem.len(body.?) > 0);
    request_deallocate(body.?);
}

Running both tests together:

$ zig test request.zig
All 2 tests passed.

Header File (request.h)

#ifndef _REQUEST_H
#define _REQUEST_H

/* Returns a heap‑allocated C string on success, or NULL on failure. */
char *request_wrapper(const char *url);

/* Frees a string returned by `request_wrapper`. */
void request_deallocate(char *content);

#endif // _REQUEST_H
  • In C, Zig’s [:0]const u8 maps to const char *.
  • The optional (?) type disappears – a NULL pointer signals failure.

Example C Program

#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]);
        return 1;
    }

    char *content = request_wrapper(argv[1]);
    if (!content) {
        fprintf(stderr, "Request failed\n");
        return 1;
    }

    printf("%s\n", content);
    request_deallocate(content);
    return 0;
}

The shared library will be:

  • librequest.so on Linux
  • librequest.dylib on macOS
  • request.dll on Windows

Using the Wrapper from Python

import ctypes
from pathlib import Path

# Load the shared library (adjust the suffix for your platform).
_lib_path = Path(__file__).with_name('librequest.so')
lib = ctypes.CDLL(str(_lib_path))

# Declare argument and return types.
lib.request_wrapper.argtypes = [ctypes.c_char_p]
lib.request_wrapper.restype = ctypes.c_char_p   # NULL on error

lib.request_deallocate.argtypes = [ctypes.c_char_p]
lib.request_deallocate.restype = None

def fetch(url: str) -> str:
    """Fetch `url` using the Zig HTTP client."""
    raw = lib.request_wrapper(url.encode('utf-8'))
    if not raw:
        raise RuntimeError("Request failed")
    try:
        # Convert the C string (null‑terminated) to Python `str`.
        return ctypes.string_at(raw).decode('utf-8')
    finally:
        lib.request_deallocate(raw)

# Example usage
if __name__ == "__main__":
    print(fetch("http://localhost"))
  • The Python code loads the same shared library built for C.
  • request_wrapper returns a char * that is freed with request_deallocate.
  • Errors are reported by a NULL return value.

Summary

ComponentPurpose
request (Zig)Core HTTP GET implementation, returns a C‑compatible NUL‑terminated string.
request_wrapper / request_deallocate (Zig)Exported C API – simple, zero‑copy, zero‑extra‑allocation wrapper.
request.hC header exposing the two functions.
Example C programDemonstrates native usage of the shared library.
Python snippetShows how the same shared library can be called from Python.

The whole flow stays memory‑safe (allocations and frees use the same allocator) and error‑aware (any Zig error becomes a NULL pointer for the caller). This pattern can be reused for any Zig functionality you wish to expose to C‑compatible languages.

Overview

The article explains how to expose a Zig library to Python using ctypes.
All the FFI (Foreign Function Interface) complexity is wrapped inside a small Python class, making the library easy to use from Python code.

Python Wrapper

import ctypes

class Request:
    def __init__(self):
        # Load the shared library
        self.lib = ctypes.CDLL("./librequest.so")

        # Declare the function signatures
        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:
        # Call the Zig function
        result = self.lib.request_wrapper(url.encode())
        if not result:
            raise RuntimeError("Request failed")

        # Find the terminating NUL byte
        i = 0
        while result[i] != b'\0':
            i += 1

        # Convert the C string to a Python string
        content = result[:i].decode()

        # Free the memory allocated by Zig
        self.lib.request_deallocate(result)

        return content

How It Works

StepDescription
__init__Loads the shared library with CDLL.
argtypes = [ctypes.c_char_p]Declares that the function expects a C‑style string.
restype = ctypes.POINTER(ctypes.c_char)Declares that the function returns a pointer to characters.
get
  • Encode the Python URL to bytes (C string).
  • Call the Zig function and check for NULL.
  • Manually locate the terminating \0 byte.
  • Slice the bytes up to that point and decode to a Python string.
  • Call request_deallocate to free the memory allocated by Zig.

Using the Wrapper

import request

req = request.Request()
body = req.get("http://localhost")
print(body)

All the FFI details are hidden inside Request; the consumer simply works with ordinary Python objects.

Extending the Example

You can build on this foundation in many ways:

  • Add more HTTP methods – POST, PUT, DELETE, etc. (same pattern).
  • Expose configuration options – time‑outs, custom headers, authentication.
  • Integrate computationally intensive algorithms written in Zig.
  • Wrap other Zig libraries that interact with the operating system.

Key Takeaways

  1. Memory management is explicit – you must know who allocates and who frees memory. Zig forces you to be explicit, which helps avoid leaks but requires discipline.
  2. Types must match – strings and arrays have different representations in each language; conversions must be exact.
  3. Error handling needs translation – Zig’s error model isn’t the same as C’s. In this example we use NULL to signal failure, but other strategies are possible.
  4. Test in layers – first test the Zig code alone, then from C, and finally from Python. This isolates the source of any problem.

The same approach can be applied to HTTP or any other functionality you want to expose from Zig to Python.

Note: This article was originally written in Spanish.

0 views
Back to Blog

Related posts

Read more »