Foundation Step 6 of 39
learn > zig structs vs pydantic models

Zig Structs vs Pydantic Models

In FastAPI, Pydantic models are the backbone of request and response handling. You define a class that inherits from BaseModel, declare typed fields, and Pydantic handles serialisation, deserialisation, and validation at runtime.

In Zigmund, plain Zig structs fill the same role. There is no BaseModel to inherit from and no class hierarchy. A struct is a collection of typed fields -- the Zig compiler enforces the types at compile time, and Zigmund's injector handles JSON deserialisation and validation at runtime.

This guide explains how to translate your Pydantic mental model into Zig structs and covers the validation mechanisms Zigmund provides.


Basic Struct Definition

A Pydantic model in FastAPI:

from pydantic import BaseModel

class Item(BaseModel):
    name: str
    price: float
    in_stock: bool = True

The equivalent Zig struct:

const Item = struct {
    name: []const u8,
    price: f64,
    in_stock: bool = true,
};

Key differences:

Concept Pydantic Zig struct
String type str []const u8 (a byte slice)
Float type float f64 (64-bit float)
Integer type int u32, i64, etc. (explicit size)
Boolean type bool bool
Default values field: type = default field: type = default
Inheritance class Child(Parent) Not supported -- use composition

The default value syntax is identical: in_stock: bool = true in Zig works the same as in_stock: bool = True in Python.


Optional Fields

In Python:

from typing import Optional

class User(BaseModel):
    name: str
    bio: Optional[str] = None

In Zig:

const User = struct {
    name: []const u8,
    bio: ?[]const u8 = null,
};

The ? prefix makes a type optional in Zig. ?[]const u8 means "either a string or null." Combined with a default value of null, this creates a field that can be omitted from the JSON payload.

Accessing optional fields

// Check if present
if (user.bio) |bio_value| {
    // bio_value is []const u8 (unwrapped)
    std.debug.print("Bio: {s}\n", .{bio_value});
} else {
    std.debug.print("No bio provided\n", .{});
}

// Or use orelse for a default
const bio = user.bio orelse "No bio";

Using Structs with Body(T, .{})

To receive a JSON request body as a struct, use the Body marker:

const ItemPayload = struct {
    name: []const u8,
    price: f64,
    in_stock: bool = true,
};

fn createItem(
    item: zigmund.Body(ItemPayload, .{}),
    allocator: std.mem.Allocator,
) !zigmund.Response {
    const payload = item.value.?;
    return zigmund.Response.json(allocator, .{
        .name = payload.name,
        .price = payload.price,
        .in_stock = payload.in_stock,
    });
}

Zigmund's injector deserialises the JSON request body into an ItemPayload struct. Fields that are missing from the JSON use their default values. Fields without defaults that are missing from the JSON produce a 422 validation error.

Comparison with FastAPI

# FastAPI
class ItemPayload(BaseModel):
    name: str
    price: float
    in_stock: bool = True

@app.post("/items")
def create_item(item: ItemPayload):
    return {
        "name": item.name,
        "price": item.price,
        "in_stock": item.in_stock,
    }
// Zigmund
const ItemPayload = struct {
    name: []const u8,
    price: f64,
    in_stock: bool = true,
};

fn createItem(
    item: zigmund.Body(ItemPayload, .{}),
    allocator: std.mem.Allocator,
) !zigmund.Response {
    const payload = item.value.?;
    return zigmund.Response.json(allocator, .{
        .name = payload.name,
        .price = payload.price,
        .in_stock = payload.in_stock,
    });
}

The patterns are strikingly similar. The main syntactic difference is the Body(ItemPayload, .{}) marker type instead of a type annotation, and the .value.? unwrap to access the parsed struct.


Compile-Time vs Runtime Validation

One of the most important differences between Pydantic and Zig structs is when type validation occurs.

Pydantic: runtime validation

Pydantic validates types at runtime. If you send {"price": "not a number"}, Pydantic catches it when deserialising and returns a 422 error. The Python type system itself does not enforce types -- they are just hints.

Zig: compile-time type checking + runtime parsing

Zig enforces types at compile time. You cannot assign a []const u8 to a f64 field -- the compiler will reject it. However, since JSON data arrives as text at runtime, Zigmund still performs runtime parsing to convert JSON values to Zig types. If the JSON contains "price": "not a number", the JSON parser will fail and Zigmund returns a 422 error.

The benefit of compile-time checking is that you cannot accidentally misuse types in your handler code:

const Item = struct {
    name: []const u8,
    price: f64,
};

fn handler(item: zigmund.Body(Item, .{}), allocator: std.mem.Allocator) !zigmund.Response {
    const payload = item.value.?;
    // This would be a COMPILE ERROR:
    // const total = payload.name * 2;
    // The compiler knows `name` is []const u8, not a number.

    const total = payload.price * 2;  // This is fine -- price is f64.
    return zigmund.Response.json(allocator, .{ .total = total });
}

Response Model Filtering

In FastAPI, you use response_model to filter which fields appear in the response:

class PublicUser(BaseModel):
    id: int
    username: str

@app.get("/users/me", response_model=PublicUser)
def read_user():
    # Returns extra fields, but only id and username appear in the response
    return {"id": 7, "username": "alice", "email": "alice@example.com", "admin": True}

Zigmund supports the same pattern through the response_model route option:

const PublicUser = struct {
    id: u32,
    username: []const u8,
};

fn readUser(req: *zigmund.Request, allocator: std.mem.Allocator) !zigmund.Response {
    _ = req;
    // Return all fields -- response_model will filter
    return zigmund.Response.json(allocator, .{
        .id = 7,
        .username = "alice",
        .email = "alice@example.com",
        .admin = true,
    });
}

// Registration
try app.get("/users/me", readUser, .{
    .response_model = PublicUser,
});

The response_model = PublicUser option tells Zigmund to filter the JSON response so that only fields present in PublicUser (id and username) appear in the output. The email and admin fields are stripped.

Additional response model options

Zigmund provides several route options that control response model behavior, mirroring FastAPI's response model features:

try app.get("/users/me", readUser, .{
    .response_model = PublicUser,
    .response_model_include = &.{"id", "username"},    // Only include these fields
    .response_model_exclude = &.{"internal_id"},       // Exclude these fields
    .response_model_exclude_unset = false,             // Exclude fields not explicitly set
    .response_model_exclude_defaults = false,          // Exclude fields with default values
    .response_model_exclude_none = false,              // Exclude fields with null values
    .response_model_by_alias = true,                   // Use alias names in output
});

Nested Structs

Just as Pydantic supports nested models, Zig supports nested structs:

# FastAPI / Pydantic
class Address(BaseModel):
    street: str
    city: str
    country: str = "US"

class User(BaseModel):
    name: str
    address: Address
// Zigmund / Zig
const Address = struct {
    street: []const u8,
    city: []const u8,
    country: []const u8 = "US",
};

const User = struct {
    name: []const u8,
    address: Address,
};

When used with Body(User, .{}), the JSON parser handles nested objects automatically:

{
    "name": "Alice",
    "address": {
        "street": "123 Main St",
        "city": "Springfield",
        "country": "US"
    }
}

Nesting can go as deep as needed. Zigmund will recursively deserialise nested objects into their corresponding struct types.


Field-Level Validation with zigmund_field_constraints

While Pydantic uses Field(...) with validators for per-field constraints, Zigmund uses a zigmund_field_constraints declaration on the struct. This provides runtime validation for numeric bounds, string lengths, and regex patterns on individual fields:

const CreateUser = struct {
    username: []const u8,
    age: u32,
    email: []const u8,

    pub const zigmund_field_constraints = .{
        .username = .{
            .min_length = @as(?usize, 3),
            .max_length = @as(?usize, 50),
        },
        .age = .{
            .ge = @as(?f64, 0),
            .le = @as(?f64, 150),
        },
        .email = .{
            .pattern = @as(?[]const u8, "^[^@]+@[^@]+\\.[^@]+$"),
        },
    };
};

When a Body(CreateUser, .{}) parameter is parsed, the injector automatically checks each constrained field after deserialisation. If a constraint is violated, a 422 validation error is returned with details about which field failed and why.

Comparison with Pydantic

from pydantic import BaseModel, Field

class CreateUser(BaseModel):
    username: str = Field(min_length=3, max_length=50)
    age: int = Field(ge=0, le=150)
    email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")

The constraint names (min_length, max_length, ge, le, gt, lt, pattern) are intentionally identical to Pydantic's naming conventions.

Available field constraints:

Constraint Type Description
gt ?f64 Greater than
ge ?f64 Greater than or equal
lt ?f64 Less than
le ?f64 Less than or equal
min_length ?usize Minimum string length
max_length ?usize Maximum string length
pattern ?[]const u8 Regex pattern

Constraints are validated recursively for nested structs, so deeply-nested models propagate constraints automatically.


Model-Level Validation with zigmund_validate

For validation that spans multiple fields or requires custom logic, Zigmund supports a zigmund_validate declaration on the struct. This is similar to Pydantic's @model_validator:

const DateRange = struct {
    start_date: []const u8,
    end_date: []const u8,

    pub fn zigmund_validate(self: DateRange) !void {
        // Custom validation: end_date must come after start_date
        if (std.mem.order(u8, self.start_date, self.end_date) != .lt) {
            return error.EndDateBeforeStartDate;
        }
    }
};

The validator function can have two signatures:

// Simple form -- receives only the struct value
pub fn zigmund_validate(self: MyStruct) !void { ... }

// Extended form -- also receives the request for additional context
pub fn zigmund_validate(self: MyStruct, req: *zigmund.Request) !void { ... }

If the validator returns an error, the framework produces a 422 validation error response. The error name is included in the response for debugging.

Comparison with Pydantic

from pydantic import BaseModel, model_validator

class DateRange(BaseModel):
    start_date: str
    end_date: str

    @model_validator(mode="after")
    def validate_range(self):
        if self.start_date >= self.end_date:
            raise ValueError("end_date must come after start_date")
        return self
const DateRange = struct {
    start_date: []const u8,
    end_date: []const u8,

    pub fn zigmund_validate(self: DateRange) !void {
        if (std.mem.order(u8, self.start_date, self.end_date) != .lt) {
            return error.EndDateBeforeStartDate;
        }
    }
};

Response Model Aliases

Pydantic supports field aliases with Field(alias="..."). Zigmund provides a similar mechanism through zigmund_response_aliases:

const UserResponse = struct {
    user_id: u32,
    full_name: []const u8,

    pub const zigmund_response_aliases: []const zigmund.ResponseModelAlias = &.{
        .{ .path = "user_id", .alias = "userId" },
        .{ .path = "full_name", .alias = "fullName" },
    };
};

When this struct is used as a response_model, the output JSON uses the alias names instead of the field names:

{
    "userId": 42,
    "fullName": "Alice Smith"
}

Response Model Transforms and Computed Fields

For advanced response shaping, structs used as response models can declare transform functions and computed fields:

zigmund_response_transform

A function that receives the JSON value and can modify it in place before the response is sent:

const OrderResponse = struct {
    items: []const Item,
    subtotal: f64,

    pub fn zigmund_response_transform(
        value: *std.json.Value,
        allocator: std.mem.Allocator,
    ) anyerror!void {
        // Add a computed field to the JSON
        _ = allocator;
        // Modify `value` in place...
    }
};

zigmund_computed_fields

Declares fields that are computed from the response data at runtime:

const OrderResponse = struct {
    quantity: u32,
    unit_price: f64,

    pub const zigmund_computed_fields: []const zigmund.ComputedFieldEntry = &.{
        .{
            .name = "total",
            .compute = &computeTotal,
        },
    };

    fn computeTotal(
        fields: std.json.ObjectMap,
        allocator: std.mem.Allocator,
    ) anyerror!std.json.Value {
        _ = allocator;
        const qty = fields.get("quantity") orelse return .{ .float = 0 };
        const price = fields.get("unit_price") orelse return .{ .float = 0 };
        return .{ .float = qty.float * price.float };
    }
};

No Class Inheritance -- Composition Instead

Zig does not have classes or inheritance. Where Pydantic uses inheritance to share fields between models, Zig uses composition (embedding one struct inside another):

# Pydantic -- inheritance
class BaseItem(BaseModel):
    name: str
    price: float

class ItemWithId(BaseItem):
    id: int
// Zig -- composition
const BaseItem = struct {
    name: []const u8,
    price: f64,
};

const ItemWithId = struct {
    base: BaseItem,
    id: u32,
};

Or you can simply repeat the fields (which is more idiomatic in Zig when the structs serve different purposes):

const CreateItem = struct {
    name: []const u8,
    price: f64,
};

const ItemResponse = struct {
    id: u32,
    name: []const u8,
    price: f64,
};

This explicit repetition may feel redundant coming from Python, but it has advantages: each struct is self-contained, there are no hidden inherited fields, and the compiler can optimize each struct independently.


Type Mapping Reference

A quick reference for translating Python types to Zig types:

Python Zig Notes
str []const u8 Byte slice (UTF-8 string)
int i32, i64, u32, u64 Choose the appropriate size
float f32, f64 Choose the appropriate precision
bool bool Same name
Optional[T] ?T ? prefix makes any type optional
list[T] []const T Slice of T
dict Not directly supported Use a struct with known fields
Any std.json.Value Dynamic JSON value
None null Zig's null literal
datetime []const u8 or zigmund.DateTime String or typed wrapper
UUID []const u8 or zigmund.Uuid String or typed wrapper

Summary

Concept Pydantic Zig Structs in Zigmund
Model definition class Item(BaseModel): const Item = struct { ... };
Field types Runtime-checked annotations Compile-time enforced types
Default values field: type = default field: type = default
Optional fields Optional[T] = None ?T = null
JSON deserialization Automatic via Pydantic Via Body(T, .{}) marker
Field validation Field(min_length=3) zigmund_field_constraints
Model validation @model_validator zigmund_validate function
Response filtering response_model=Model .response_model = Model
Field aliases Field(alias="name") zigmund_response_aliases
Inheritance class Child(Parent): Composition or field repetition
Computed fields @computed_field zigmund_computed_fields

Zig structs are simpler than Pydantic models -- there is no metaclass magic, no runtime schema generation, and no class hierarchy. What you see is what you get. The trade-off is that some conveniences require explicit declarations (like zigmund_field_constraints), but the benefit is complete transparency, compile-time safety, and zero runtime overhead for type checking.