Using Zig Functions from Python
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
| Step | What Happens |
|---|---|
| URL handling | [:0]const u8 is converted to a normal slice for std.Uri.parse. |
| Client creation | std.http.Client is instantiated with the caller‑provided allocator. |
| Request | client.request(.GET, uri, .{}) builds a GET request. |
| Send | req.sendBodiless() sends the request (no body). |
| Receive | req.receiveHead(&redirect_buffer) reads the response headers. |
| Body collection | ArrayListUnmanaged(u8) grows as needed; appendRemainingUnlimited reads the full body. |
| NUL terminator | list.append(allocator, 0) adds the required C‑style terminator. |
| Return | The 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
localhostfor 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 u8maps toconst char *. - The optional (
?) type disappears – aNULLpointer 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.soon Linuxlibrequest.dylibon macOSrequest.dllon 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_wrapperreturns achar *that is freed withrequest_deallocate.- Errors are reported by a
NULLreturn value.
Summary
| Component | Purpose |
|---|---|
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.h | C header exposing the two functions. |
| Example C program | Demonstrates native usage of the shared library. |
| Python snippet | Shows 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
| Step | Description |
|---|---|
__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 |
|
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
- 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.
- Types must match – strings and arrays have different representations in each language; conversions must be exact.
- Error handling needs translation – Zig’s error model isn’t the same as C’s. In this example we use
NULLto signal failure, but other strategies are possible. - 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.