hyperdjango
Following up from opsmas 2025 is spring cleaning 2026. Welcome!
To kick off spring cleaning, the first project I’d like to release
is: hyperdjango.
What is hyperdjango you may ask?
You could jump ahead into all docs at the hyperdjango hyperdocs hypersite and find out.
Though, what you should be asking: what isn’t hyperdjango?
Hyperdjango contains:
- obviously, a “django,” by which we mean an Active Record style ORM
system with:
- transparent database access (i.e. you don’t “bring the database object” with you everywhere, the database is a global property of the ORM classes and objects themselves, so the database is “always available” under any session connection)
- auto-generated admin interfaces from ORM collections
- an entire custom ORM with full performance and feature coverage
- safe defaults, pluggable extension points as much as possible
- MVC layers with ease-of-default-usage everywhere
- injectable middleware optional at various levels
- No legacy support or compat layers. Everything is for Python 3.14t GIL-Free forward ONLY
- Modern Python 3.14t GIL-Free Native Extensions for every operation possible
- Native-performance jinja2 compatible template engine (better,
faster)
- with built in binary caching of compiled templates
- Native postgres to python bridge types with fast native postgres
binary library connections (borrowed; extended)
- including native-dispatched ORM calls translated into prepared statement caches
- including support for pgvector and hstore and other types with transparent python adapters and interfaces
- native
CreateVectorIndex()support withVectorField(dimensions=N)ORM column properties
- Native code accelerated pydantic-style model validation (better, faster; borrowed, extended)
- Native code rapid url dispatching (inspired; extended)
- Native consistent hash ring logic for app-controlled replication and fan-out cache scenarios
- Advanced ORM query caching to avoid redundant ORM query string generations (HyperORM)
- Advanced application RBAC and custom per-route permission management (HyperGuard)
- No “san francisco big tech tech delusion fad bullshit” like react or next or vue or nuxt or bundlers or transpilers or npm toxins turning the friggin frogs gay or anything else. just html templates and progressive-enhancement javascript all the way down, like the goddamn internet was supposed to be.
- Continues more modern approaches returning to the old ways of scripting languages being interfaces to more high-performing underlying libraries and interfaces instead of mega-boxed-vm-overhead management of entire platforms end to end (like where I improved a pre-existing system doing 64,000 operations per second to, after smashing a dozen layers of computational-wasting “abstractions,” to over 22 million operations per second just by pushing everything down into native code instead of driving up and down the elevator of hierarchical python bloated allocator slop all the time).
Many of the advanced hyperdjango hyperapp features can be used with “regular” django apps as plugin-compatible config settings, but some features are better used in a full hyperdjango hyperapp system.
Also hyperdjango ships plenty of example hyperapps illustrating all features in various combinations in addition to thousands of test cases for as much feature and performance validation coverage as I could get designed and built.
I don’t want to exhaust you with full architecture and feature detail rundowns here, but you can browse over 40,000 lines of markdown docs and over 30,000 lines of example code in the repo.
Highlights
Here are some highlights though:
HyperGuard
per-route dynamic RBAC on demand guarded visually AT ROUTE DEFINITIONS instead of sprinkled (dangerously) inside routes themselves where you eventually forget them when adding something new and opening up security or permission edge case exploits.
The RBAC system is natively aware of users and groups and permissions and routes and you can combine arbitrary queries and efficient cached lookups in any combination before a route is even hit; and these are illustrated at route time to maintain a clean LOB interface instead of other systems where you would do something simliar by injecting per-route “middleware” defined much further out-of-scope where used and just referenced in-line in mutiple places (if you remember to even do it consistently everywhere).
from hyperdjango.guard import guard, Require
@app.post("/f/{forum_name}/submit")
@guard(
Require.authenticated(redirect_url="/login"),
Require.not_banned(),
Require.resource("forum", resolver=resolve_forum_write, from_path="forum_name"),
)
async def forum_submit(request, forum_name: str):
forum = request.guard.forum # Resolved and validated by guard
...Require.any_of(
Require.staff(),
Require.check("is_mod", fn=check_is_mod),
)def make_forum_resolver(intent: ForumIntent):
async def resolver(request, ctx, forum_name):
forum = await Forum.objects.filter(name=forum_name).first()
if not forum:
return None
# ... intent-specific checks (archived, locked, private, mod, admin)
return ForumAccess(forum, is_member, is_mod, membership)
return resolver
resolve_read = make_forum_resolver(ForumIntent.READ)
resolve_write = make_forum_resolver(ForumIntent.WRITE_POST)
resolve_moderate = make_forum_resolver(ForumIntent.MODERATE)
resolve_admin = make_forum_resolver(ForumIntent.ADMIN)
# Routes declare intent via which resolver they use:
@guard(Require.authenticated(), Require.resource("access", resolver=resolve_read, from_path="name"))
@guard(Require.authenticated(), Require.resource("access", resolver=resolve_write, from_path="name"))
@guard(Require.authenticated(), Require.resource("access", resolver=resolve_admin, from_path="name"))@app.websocket("/ws/chat")
@guard_websocket(auth, Require.authenticated())
async def chat(ws):
user = ws.user # Authenticated user dict
guard = ws.guard # GuardContext# Require user belongs to a specific RBAC group
@guard(Require.authenticated(), Require.group("editors"))
async def edit_article(request):
...# Model with timeline categories
class User(StatusTimelineMixin, TimestampMixin, Model):
class TimelineConfig:
entity_type = "user"
categories = {
"moderation": ["banned", "muted", "warned"],
"access": ["staff", "moderator"],
}
# Guard checks timeline status (no boolean flags needed)
@guard(
Require.authenticated(),
Require.no_active_status("moderation", "banned"), # Not currently banned
Require.has_active_status("access", "staff"), # Currently staff
)
async def admin_panel(request):
...
# Set status with actor attribution + optional expiry
await user.set_status("moderation", "banned",
reason="Repeated spam", actor_id=admin.id,
expires_in=timedelta(days=30))
# Clear status
await user.clear_status("moderation",
reason="Appeal approved", actor_id=admin.id)
# Query history
history = await user.get_status_history("moderation")Advanced Database ID Protection (ani-enumeration) System
External-facing identifiers for API-safe object references. Prevents IDOR/BOLA attacks by never exposing sequential integer primary keys in URLs, responses, or logs.
Provides great defenses against massive unauthorized bot crawlers since under our encoded ID system you can’t generate your own IDs without asking the system for IDs first, so you physically cannot “page through” millions or billions of records without asking the system for IDs for those records or pages, and since you have to ask the system for those records or those pages, the system itself can implement anti-bot protection before handing out new IDs. Also, IDs can be bound per accessing user or time-limited with start-end ranges, or a start date or just and end date.
Also, related to the ID generation system is a secure authenticated cursor-or-pagination system where users (or, again, abusive big tech or global ai scraper bots stealing your public data for private profit motives without compensation) cannot enumerate the pagination of your collections. Every client must ask the system for the next valid authenticated page cursor URL, and since clients have to ask the system for next page ids, you can implement your own anti-bot detection if too many page ids are requested at once (plus, inside of each page, you could have individual also database id authenticated elements as well (described below) for more control or limiting how much a requesting client can access or iterate at once before being banned for too-much-too-fast causes).
Why External IDs Matter
Sequential integer PKs (/api/posts/1,
/api/posts/2) are a security liability:
- Enumeration – Attackers iterate PKs to scrape every record in the table.
- Bot amplification – Bots can guess valid IDs without authentication probes.
- Information leakage – PK values reveal creation order, total record count, and growth rate (not entirely protected by our solution, but could be augmented and added later).
- IDOR/BOLA – Insecure Direct Object Reference attacks exploit predictable IDs to access other users’ data.
HyperDjango’s ID system keeps integer PKs internal (fast joins, compact indexes) and presents opaque, unforgeable strings at the API boundary.
Two Systems
HyperDjango provides two ID systems. IDMixin is the
recommended approach for new code. PublicIDMixin is the
legacy system retained for backward compatibility.
| System | Class | Approach |
|---|---|---|
| IDMixin (new) | IDMixin + IDConfig +
IDManager |
Derives external IDs from PK at runtime. No extra DB column (except random mode). |
| PublicIDMixin (legacy) | PublicIDMixin + PublicIDConfig |
Stores a public_id column in the database. Generated on
first save. |
IDMode and IDStrategy Enums
from hyperdjango.public_id import IDMode, IDStrategy
# IDMode -- controls external ID representation
IDMode.RAW # "raw"
IDMode.ENCODED # "encoded"
IDMode.SIGNED # "signed"
IDMode.RANDOM # "random"
# IDStrategy -- controls PublicIDMixin generation
IDStrategy.RANDOM # "random"
IDStrategy.UUID7 # "uuid7"
IDStrategy.ENCODED_PK # "encoded_pk"class Post(IDMixin, Model):
class IDConfig:
mode = IDMode.SIGNED # not "signed"
alphabet = "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
hmac_keys = ["key-2025-q1"]4 ID Modes (IDMixin)
| Mode | Format | DB Column | Enumerable | Use Case |
|---|---|---|---|---|
signed |
{encoded}.{hmac_hex} |
None | No | Default. Public APIs, user-facing URLs. |
encoded |
Base-N encoded PK | None | Yes (bijection) | Internal services with trusted callers. |
raw |
Integer string | None | Yes | Internal/admin APIs only. |
random |
Random opaque string | public_id |
No | Legacy compatibility, pre-existing schemas. |
Quick Start
Signed mode is the recommended default. It derives an unforgeable external ID from the integer PK without storing anything extra in the database.
from hyperdjango.public_id import IDMixin, generate_alphabet
from hyperdjango.models import Model, Field
# Step 1: Generate an alphabet (one time, copy the output into code)
# >>> from hyperdjango.public_id import generate_alphabet
# >>> print(generate_alphabet("olc32"))
# "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
class Post(IDMixin, Model):
class Meta:
table = "posts"
class IDConfig:
mode = IDMode.SIGNED
alphabet = "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
hmac_keys = ["key-2024-q1"]
id: int = Field(primary_key=True, auto=True)
title: str = Field()
# Encode PK to external ID
post = await Post.objects.get(id=42)
external_id = post.get_external_id()
# "cX9.a1b2c3d4e5f6a1b2"
# Decode external ID back to PK
pk = Post.decode_external_id(external_id)
# 42
# Verify without decoding
Post.verify_external_id(external_id)
# True
# Invalid/forged IDs raise ValueError
Post.decode_external_id("cX9.0000000000000000")
# ValueError: Invalid signed ID: signature verification failedIDConfig Reference
All options with their defaults:
class IDConfig:
mode: IDMode = IDMode.SIGNED
# IDMode.RAW, IDMode.ENCODED, IDMode.SIGNED, or IDMode.RANDOM
alphabet: str = ""
# Required for encoded/signed modes.
# A random permutation of a base character set.
# Generate with: generate_alphabet("olc32") or generate_alphabet("base62")
hmac_keys: list[str] = []
# Required for signed mode. Ordered newest-first.
# The first key is used for signing; all keys are tried for verification.
signature_bytes: int = 8
# HMAC truncation length. 8 bytes = 16 hex characters.
# Minimum recommended: 8 (64 bits). Increase for higher-security contexts.
include_user: bool = False
# Per-user signing. When True, user_id is included in the HMAC input,
# producing different external IDs for the same object per user.
separator: str = "."
# Character between the encoded value and the HMAC signature.
table_name: str = ""
# Included in HMAC input for table isolation.
# Auto-detected from Meta.table if empty, falls back to lowercase class name.
# Random mode only:
entropy_bytes: int = 10
# Bytes of randomness for random ID generation.
strategy: IDStrategy = IDStrategy.RANDOM
# "random" or "uuid7" (for random mode only).Alphabet Generation
Generate a unique alphabet for each model. Never reuse alphabets across models.
from hyperdjango.public_id import generate_alphabet
# 32-char alphabet: no vowels (can't spell words), no confusables (0/O, 1/l/I)
generate_alphabet("olc32")
# "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
# 62-char alphabet: full alphanumeric, higher density (5.95 bits/char vs 5.0)
generate_alphabet("base62")
# "tR4kL9xZ..."Width reference for planning ID length:
| Base | Chars | Bits | Covers |
|---|---|---|---|
| 32 | 6 | 30 | 1 billion values |
| 32 | 8 | 40 | 1 trillion values |
| 32 | 13 | 65 | BIGSERIAL max |
| 62 | 6 | 35 | 34 billion values |
| 62 | 8 | 47 | 140 trillion values |
| 62 | 11 | 65 | BIGSERIAL max |
Signed Mode Deep Dive
Signed mode is the recommended default. It provides anti-enumeration, anti-forgery, and table isolation without any extra database storage.
How It Works
Internal PK (integer)
|
v
BaseEncoder.encode(pk) # Bijective base-N encoding
| # e.g., 42 -> "cX9"
v
HMAC-SHA256(key, "posts:cX9") # Sign with table name + encoded value
|
v
Truncate to signature_bytes # 8 bytes = 16 hex chars
|
v
"{encoded}.{signature}" # "cX9.a1b2c3d4e5f6a1b2"
Why It Prevents Enumeration
An attacker who knows cX9.a1b2c3d4e5f6a1b2 is PK 42
cannot compute the external ID for PK 43 without the HMAC key. The
encoding step is a bijection (reversible), but the signature step
requires the secret key.
Signature Format
cX9.a1b2c3d4e5f6a1b2
^ ^
| +-- HMAC-SHA256 hex digest, truncated to signature_bytes * 2 chars
+-- Base-N encoded PK
The separator (. by default) divides the encoded value
from the signature. Decoding splits on the last separator occurrence, so
encoded values containing the separator character are handled
correctly.
Table Isolation
The HMAC input includes the table name: "posts:cX9".
This means the same PK in different tables produces different external
IDs:
# PK 42 in "posts" table
post.get_external_id() # "cX9.a1b2c3d4e5f6a1b2"
# PK 42 in "comments" table (different alphabet, different table name)
comment.get_external_id() # "Rm7.f8e7d6c5b4a3f8e7"An external ID valid for the posts table will fail
signature verification against the comments table, even if
both use the same HMAC key.
Constant-Time Comparison
Signature verification uses hmac.compare_digest(), which
runs in constant time regardless of where the first mismatch occurs.
This prevents timing attacks that could incrementally guess the correct
signature.
Key Rotation
HMAC keys are stored as a list, ordered newest-first. The first key signs new IDs; all keys are tried during verification.
Adding a New Key
Add the new key at position 0. Existing IDs signed with the old key continue to verify.
class Post(IDMixin, Model):
class IDConfig:
mode = IDMode.SIGNED
alphabet = "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
hmac_keys = [
"key-2025-q1", # NEW — used for signing
"key-2024-q1", # OLD — still accepted for verification
]What happens:
- New external IDs are signed with
key-2025-q1. - Incoming IDs signed with
key-2024-q1still verify (tried second). - No IDs are invalidated. No database migration needed.
Removing an Old Key
After sufficient time (all cached/bookmarked/stored external IDs have been refreshed), remove the old key:
class Post(IDMixin, Model):
class IDConfig:
mode = IDMode.SIGNED
alphabet = "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
hmac_keys = [
"key-2025-q1", # Only key — old IDs no longer verify
]Warning: Removing a key invalidates all external IDs signed with it. Plan a deprecation window.
Example Rotation Schedule
| Quarter | hmac_keys | Effect |
|---|---|---|
| 2024-Q1 | ["key-2024-q1"] |
Initial deployment |
| 2025-Q1 | ["key-2025-q1", "key-2024-q1"] |
New key added, old still valid |
| 2025-Q3 | ["key-2025-q1"] |
Old key removed after 6-month window |
| 2026-Q1 | ["key-2026-q1", "key-2025-q1"] |
Next rotation |
Key Entropy Requirements
HMAC keys should have at least 256 bits of entropy. Generate with:
import secrets
key = f"key-2025-q1-{secrets.token_hex(32)}"
# "key-2025-q1-a1b2c3d4...64 hex chars..."Per-User Signing
When include_user=True, the user’s ID is included in the
HMAC input. The same object produces different external IDs for
different users.
When to Use
- Sensitive APIs where users should not share or compare IDs.
- Per-user data isolation (e.g., medical records, financial data).
- Preventing ID-sharing attacks where a valid ID from one user’s session is replayed in another’s.
How It Works
HMAC input (standard): "posts:cX9"
HMAC input (per-user): "posts:cX9:17" # user_id=17
class MedicalRecord(IDMixin, Model):
class IDConfig:
mode = IDMode.SIGNED
alphabet = "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
hmac_keys = ["key-2025-q1"]
include_user = True
# Same record, different external IDs per user
record.get_external_id(user_id=17) # "cX9.a1b2c3d4e5f6a1b2"
record.get_external_id(user_id=42) # "cX9.7f8e9d0c1b2a3f4e"
# Decoding requires the correct user_id
MedicalRecord.decode_external_id("cX9.a1b2c3d4e5f6a1b2", user_id=17) # 42
MedicalRecord.decode_external_id("cX9.a1b2c3d4e5f6a1b2", user_id=42)
# ValueError: signature verification failedPreventing ID Sharing
If user A copies an external ID from their browser and sends it to user B, user B cannot use it – the signature is bound to user A’s identity. The server returns 404 (not 400 or 403), preventing information leakage about whether the object exists.
Time-Windowed IDs
Create IDs that are only valid within a specific time window — fully stateless, no database lookup needed for time validation.
How It Works
Time constraints are embedded in the external ID itself, covered by the HMAC:
Standard: {encoded_pk}.{hmac}
Time-windowed: {encoded_pk}.{start}-{end}.{hmac}
- Timestamps encoded compactly using the same alphabet
"0"means no limit on that side- HMAC covers the time window — can’t tamper without breaking signature
- On decode: verify HMAC, check
start <= now <= end, decode PK - Expired/not-yet-valid → same error as invalid signature (no info leakage)
Usage
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
# Valid for the next 24 hours
manager.encode(pk=42, valid_until=now + timedelta(hours=24))
# Valid starting Feb 1 through Feb 10
manager.encode(
pk=42,
valid_after=datetime(2025, 2, 1, tzinfo=timezone.utc),
valid_until=datetime(2025, 2, 10, tzinfo=timezone.utc),
)
# Valid only after a future date (embargo)
manager.encode(pk=42, valid_after=datetime(2025, 3, 1, tzinfo=timezone.utc))Use Cases
- Expiring share links: “This link expires in 48 hours”
- Embargo content: “Available after March 1”
- Time-limited API tokens: Access windows for temporary integrations
- Promotional URLs: “Valid Feb 1-14 only”
KeySlot Dataclass
The KeySlot dataclass represents a single HMAC key with
optional PK offset and custom epoch. It is the building block for
hmac_keys lists in signed mode.
from hyperdjango.public_id import KeySlot
slot = KeySlot(
key="secret-key-2025-q1", # HMAC signing key (required)
offset=50_000, # Added to PK before encoding, subtracted after decoding
epoch=1704240000, # Custom Unix timestamp for time-window calculations
)| Field | Type | Default | Description |
|---|---|---|---|
key |
str |
– | HMAC-SHA256 signing key. Must have at least 256 bits of entropy. |
offset |
int |
0 |
Added to PK before encoding. Prevents low PKs from producing short, predictable external IDs. |
epoch |
int |
0 |
Custom Unix timestamp used as the base for time-window calculations. 0 means standard Unix epoch. |
Auto-Normalization of String Keys
When hmac_keys contains plain strings, they are
automatically normalized to KeySlot instances with
offset=0 and epoch=0. You can mix strings and
KeySlot objects freely:
class Article(IDMixin, Model):
class IDConfig:
mode = IDMode.SIGNED
alphabet = "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
hmac_keys = [
KeySlot("key-2025-q2", offset=100_000, epoch=1735689600),
"key-2025-q1", # auto-normalized to KeySlot("key-2025-q1", offset=0, epoch=0)
]This lets you add offsets and epochs incrementally without rewriting existing key entries.
Custom Epoch
Each KeySlot can define a custom epoch — a base timestamp for time calculations. Instead of encoding full Unix timestamps (large numbers → long strings), times are stored relative to the epoch (small numbers → short strings).
Why Use It
| Epoch | Feb 1, 2025 encoded as | String length |
|---|---|---|
| Unix (1970) | 1738368000 | ~7 chars |
| Jan 3, 2024 | 34128000 | ~5 chars |
Shorter encodings → shorter URLs. Also makes time values harder to reverse-engineer since attackers don’t know your epoch.
Configuration
from hyperdjango.public_id import KeySlot
# Epoch = Jan 3, 2024 00:00 UTC (Unix timestamp 1704240000)
KeySlot(
key="secret-key-2025",
offset=50_000,
epoch=1704240000, # Custom epoch
)Rotation with Epochs
Each KeySlot has its own epoch. When rotating keys, you can also change the epoch:
hmac_keys = [
KeySlot("key-2025-q2", offset=100_000, epoch=1735689600), # Jan 1, 2025
KeySlot("key-2025-q1", offset=50_000, epoch=1704240000), # Jan 3, 2024
]Old IDs decode correctly using their original key’s epoch. New IDs use the newest key’s epoch for even shorter encodings.
Encoded Mode
Simple bijective encoding without HMAC signing. The external ID is a reversible transformation of the PK.
class InternalWidget(IDMixin, Model):
class IDConfig:
mode = IDMode.ENCODED
alphabet = "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
widget.get_external_id() # "cX9"
InternalWidget.decode_external_id("cX9") # 42Properties:
- No HMAC key needed.
- Deterministic: same PK always produces the same external ID.
- Enumerable: an attacker who knows the alphabet can decode any ID back to a PK and encode arbitrary PKs.
- Use only for internal service-to-service APIs where callers are trusted.
Raw Mode
Exposes the integer PK directly as a string. No encoding, no signing.
class AdminResource(IDMixin, Model):
class IDConfig:
mode = IDMode.RAW
resource.get_external_id() # "42"
AdminResource.decode_external_id("42") # 42Properties:
- No alphabet or HMAC keys needed.
- Fully enumerable.
- Use only for internal admin APIs behind authentication and authorization.
Random Mode
Generates a random opaque string and stores it in a
public_id database column. This is the approach used by the
legacy PublicIDMixin system.
class LegacyItem(IDMixin, Model):
class IDConfig:
mode = IDMode.RANDOM
alphabet = "W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4"
entropy_bytes = 10
public_id: str | None = Field(default=None, unique=True, index=True)Properties:
- Requires an extra database column (
public_id). - The external ID is generated once and stored; it never changes.
IDManager.encode()andIDManager.decode()are not applicable – use the storedpublic_iddirectly.- Use for schemas that already have a
public_idcolumn, or when external IDs must survive PK changes (e.g., table migrations that reassign PKs).
REST Integration
ViewSet integration auto-encodes and auto-decodes external IDs at the API boundary.
Automatic Encoding in Responses
List and detail responses replace integer PKs with external IDs:
# GET /api/posts/
# Response:
[
{"id": "cX9.a1b2c3d4e5f6a1b2", "title": "Hello"},
{"id": "Rm7.f8e7d6c5b4a3f8e7", "title": "World"}
]Automatic Decoding in Lookups
The ViewSet decodes the external ID from the URL back to a PK for database lookup:
# GET /api/posts/cX9.a1b2c3d4e5f6a1b2
# Internally resolves to: Post.objects.get(pk=42)Invalid IDs Return 404
Invalid, forged, or malformed external IDs return HTTP 404 – never 400 or any other status that distinguishes “malformed ID” from “object not found.” This prevents information leakage:
GET /api/posts/INVALID -> 404 Not Found
GET /api/posts/cX9.forged_sig -> 404 Not Found
GET /api/posts/cX9.valid_sig -> 200 OK (if exists) or 404 Not Found
An attacker cannot distinguish between a forged ID and a genuinely missing object.
Security Analysis
Anti-Enumeration
| Mode | Enumerable | Why |
|---|---|---|
signed |
No | HMAC signature requires secret key to forge. |
encoded |
Yes | Bijection is reversible by anyone who knows the alphabet. |
raw |
Yes | Integer PK is directly exposed. |
random |
No | Random string with no relationship to PK. |
Timing Attack Prevention
Signed mode uses hmac.compare_digest() for signature
comparison. This function compares in constant time, preventing timing
side-channels that could leak signature bytes incrementally.
Information Leakage Prevention
- Invalid external IDs return 404, not 400 or 403.
- No error messages distinguish between “bad format” and “not found.”
- Per-user signing prevents cross-user ID correlation.
Key Entropy
- HMAC keys must have at least 256 bits of entropy (32 bytes / 64 hex characters).
- The
signature_bytessetting controls truncation. At 8 bytes (default), there are 2^64 possible signatures per encoded value. Brute-forcing requires ~2^63 attempts on average. - Alphabets should be unique per model. Reusing an alphabet across models does not break security (table isolation handles it) but is poor practice.
Table Isolation
The table name is included in the HMAC input. Even if two models share the same HMAC key and alphabet, an external ID from one table will not verify against the other.
Key Differences
| Aspect | PublicIDMixin | IDMixin (signed) |
|---|---|---|
| Storage | Extra public_id column |
No extra column |
| Generation | On first save() |
On demand (runtime computation) |
| Determinism | Random – different each time | Deterministic – same PK always produces same ID |
| Key rotation | N/A | Built-in via hmac_keys list |
| Per-user IDs | Not supported | include_user=True |
| DB lookup | WHERE public_id = $1 |
Decode to PK, then WHERE id = $1 (uses PK index) |
BaseEncoder
The low-level encoding engine used by all modes except
raw. Performs arbitrary-base encoding with Zig-accelerated
native conversion.
from hyperdjango.public_id import BaseEncoder
encoder = BaseEncoder("W9gx3PJhF7Xc5MrQfp2vRV8mGCwq6j4")
encoder.encode(12345) # "cX9"
encoder.decode("cX9") # 12345
encoder.encode_padded(42, 8) # "44444443J" (fixed 8 chars)
encoder.encode_random(8) # random string with 8 bytes of entropy
encoder.encode_bytes(b"\x01\x02") # encode raw bytes
encoder.max_value_for_width(8) # maximum encodable value in 8 chars
encoder.width_for_bits(64) # minimum chars for 64 bits of entropyMulti-Dimensional Usage Metering
Built-in support for SaaS-style usage tracking for micro billing micro events for collection and reporting for billing or feeding into the stripe metered billing API etc. gotta be computational margin capitalismmaxxxxxxxxxxxxxing.
Track any usage across any number of dimensions. A single API call can record requests, bytes transferred, tokens consumed, and duration — all as one event. Aggregates incrementally into time buckets. Billing providers are optional downstream hook consumers.
Quick Start
from hyperdjango.metering import MeterEngine, DimensionSpec, set_meter_engine
engine = MeterEngine(db)
await engine.ensure_tables()
set_meter_engine(engine)
# Define a meter with dimensions
await engine.define_meter("api_usage", [
DimensionSpec("requests", "counter", "requests", "sum"),
DimensionSpec("bytes_in", "counter", "bytes", "sum"),
DimensionSpec("bytes_out", "counter", "bytes", "sum"),
DimensionSpec("duration_ms", "gauge", "ms", "avg"),
])
# Record a multi-dimensional event
await engine.record("api_usage", account_id="acme_corp", dimensions={
"requests": 1,
"bytes_in": 943_718_400,
"bytes_out": 2_147_483_648,
"duration_ms": 4500,
})
# Query aggregated usage
from datetime import datetime, UTC
report = await engine.query_multi("api_usage", "acme_corp",
["requests", "bytes_in", "bytes_out", "duration_ms"],
period="monthly", start=datetime(2026, 3, 1, tzinfo=UTC),
end=datetime(2026, 4, 1, tzinfo=UTC))
print(f"Requests: {report['requests'].value_sum:,.0f}")
print(f"Data in: {report['bytes_in'].value_sum / 1e9:.1f} GB")
print(f"Avg latency: {report['duration_ms'].value_avg:.0f} ms")How It Works
- Define meters with named dimensions — each dimension has a type, unit, and default aggregation
- Record events — one call with a dict of dimension values
- Aggregates update incrementally — every
record()upserts hourly, daily, and monthly rollups atomically - Query aggregates — read pre-computed rollups, not raw events
- Hooks fire on every event — for quotas, alerts, billing export, or anything custom
All tables are regular (LOGGED) — financial/accounting data survives crashes.
Defining Meters
A meter has a name and a list of dimensions. Each dimension defines what it measures:
from hyperdjango.metering import DimensionSpec
# LLM API usage — 5 dimensions per event
await engine.define_meter("llm_usage", [
DimensionSpec("requests", "counter", "requests", "sum"),
DimensionSpec("tokens_in", "counter", "tokens", "sum"),
DimensionSpec("tokens_out", "counter", "tokens", "sum"),
DimensionSpec("cost_units", "counter", "units", "sum"),
DimensionSpec("duration_ms", "gauge", "ms", "avg"),
])
# Storage tracking — gauge type (latest value matters, not sum)
await engine.define_meter("storage", [
DimensionSpec("total_bytes", "gauge", "bytes", "last"),
DimensionSpec("file_count", "gauge", "files", "last"),
])
# Seat tracking — peak in period
await engine.define_meter("seats", [
DimensionSpec("active_users", "gauge", "users", "max"),
DimensionSpec("concurrent_sessions", "gauge", "sessions", "max"),
])DimensionSpec Fields
| Field | Values | Description |
|---|---|---|
name |
any string | Dimension identifier (e.g., “tokens_in”) |
dimension_type |
"counter" "gauge"
"distribution" |
Counter = summed over time. Gauge = point-in-time value. Distribution = percentile analysis. |
unit |
any string | Human-readable unit (e.g., “bytes”, “ms”, “tokens”, “users”) |
default_agg |
"sum" "count" "last"
"max" "min" "avg" |
Default aggregation function for reports |
define_meter is idempotent — calling it again updates
the dimensions.
Recording Events
Single event
await engine.record("llm_usage", account_id="acme_corp", dimensions={
"requests": 1,
"tokens_in": 1_000_000,
"tokens_out": 2_000_000,
"cost_units": 15.5,
"duration_ms": 4500,
})One call. Five dimensions. Each value is stored as a separate
MeterEventValue row (fully relational, no JSON). The engine
also atomically upserts 5×3 = 15 aggregate rows (5 dimensions ×
hourly/daily/monthly buckets).
With idempotency key (prevent duplicates)
await engine.record("llm_usage", "acme_corp",
dimensions={"requests": 1, "tokens_out": 500_000},
idempotency_key="req-abc-123",
)
# Second call with same key is a no-op (returns -1)
await engine.record("llm_usage", "acme_corp",
dimensions={"requests": 1, "tokens_out": 500_000},
idempotency_key="req-abc-123",
) # Returns -1, not recordedWith explicit tenant
await engine.record("llm_usage", "acme_corp",
dimensions={"requests": 1},
tenant_id=42,
)If tenant_id is not passed, it auto-reads from
get_tenant() context (set by TenantMiddleware).
Storage/gauge reporting
For gauge dimensions (storage size, seat count), record the current value periodically:
# Cron job or background task: report current storage size
current_bytes = await get_storage_usage(account_id="acme_corp")
await engine.record("storage", "acme_corp", dimensions={
"total_bytes": current_bytes,
"file_count": file_count,
})The aggregate’s value_last captures the most recent
value. value_max captures the peak.
Querying Usage
Single dimension
result = await engine.query("llm_usage", "acme_corp",
"tokens_out", period="monthly",
start=datetime(2026, 3, 1, tzinfo=UTC),
end=datetime(2026, 4, 1, tzinfo=UTC))
print(f"Total tokens out: {result.value_sum:,.0f}")
print(f"Event count: {result.value_count}")
print(f"Peak single event: {result.value_max:,.0f}")
print(f"Average per event: {result.value_avg:,.0f}")Multiple dimensions
report = await engine.query_multi("llm_usage", "acme_corp",
["requests", "tokens_in", "tokens_out", "cost_units", "duration_ms"],
period="monthly", start=start, end=end)
for name, agg in report.items():
print(f" {name}: sum={agg.value_sum:,.1f} {agg.unit} "
f"(avg={agg.value_avg:,.1f}, max={agg.value_max:,.1f})")AggregateResult fields
| Field | Type | Description |
|---|---|---|
dimension_name |
str |
Dimension identifier |
unit |
str |
Unit label from dimension spec |
value_sum |
float |
Sum of all values in period |
value_count |
int |
Number of events in period |
value_min |
float \| None |
Minimum value |
value_max |
float \| None |
Maximum value |
value_last |
float \| None |
Most recent value |
value_avg |
float |
Computed: sum / count |
Hierarchical rollup (org → team → sub-account)
# Register account hierarchy
await db.execute(
"INSERT INTO hyper_meter_accounts (account_id, display_name, account_type, parent_account_id) "
"VALUES ($1, $2, $3, $4)",
"team-eng", "Engineering", "team", "acme_org",
)
# Query across entire hierarchy
org_total = await engine.query_hierarchy("llm_usage", "acme_org",
"tokens_out", "monthly", start, end)
# Includes acme_org + all sub-accounts (team-eng, team-sales, etc.)Periods
| Period | Bucket | Use case |
|---|---|---|
"hourly" |
date_trunc('hour', ...) |
Real-time dashboards, spike detection |
"daily" |
date_trunc('day', ...) |
Daily reports, trend analysis |
"monthly" |
date_trunc('month', ...) |
Billing cycles, invoicing |
Quotas
Set usage limits per account, per dimension, per period:
# Limit tokens_out to 10M per month, reject when exceeded
await engine.set_quota("acme_corp", "llm_usage", "tokens_out",
period="monthly", limit_value=10_000_000, action="reject")
# Warn at 1M requests per day
await engine.set_quota("acme_corp", "api_usage", "requests",
period="daily", limit_value=1_000_000, action="warn")Check quota manually
decision = await engine.check_quota("acme_corp", "llm_usage", "tokens_out", "monthly")
print(f"Allowed: {decision.allowed}")
print(f"Remaining: {decision.remaining:,.0f}")
print(f"Limit: {decision.limit_value:,.0f}")
print(f"Action: {decision.action}")Automatic enforcement via middleware
from hyperdjango.metering import MeteringMiddleware
app.use(MeteringMiddleware(
engine=engine,
meter_name="api_usage",
dimension_extractor=my_extractor,
quota_enforced=True, # Returns 429 when quota exceeded
))Hooks (Downstream Consumers)
Hooks are pluggable. The engine knows nothing about billing providers.
Built-in hooks
from hyperdjango.metering import QuotaEnforcementHook, AlertHook
# Auto-check quotas on every event
engine.register_hook(QuotaEnforcementHook(engine))
# Alert when tokens_out exceeds threshold in a single event
def alert_callback(ctx, dim_name, value, threshold):
print(f"ALERT: {ctx.account_id} {dim_name}={value} > {threshold}")
engine.register_hook(AlertHook(
thresholds={"tokens_out": 5_000_000},
callback=alert_callback,
))Custom billing hook (Stripe example)
from hyperdjango.metering import MeterHook, PeriodExport
class StripeSyncHook(MeterHook):
def __init__(self, stripe_client):
self.stripe = stripe_client
async def on_period_close(self, export: PeriodExport) -> None:
for dim_name, result in export.dimensions.items():
await self.stripe.billing.meter_events.create(
event_name=f"{export.meter_name}_{dim_name}",
payload={
"stripe_customer_id": lookup_stripe_id(export.account_id),
"value": str(int(result.value_sum)),
},
)
engine.register_hook(StripeSyncHook(stripe_client))The metering engine exports structured PeriodExport
data. Your hook adapts it to any provider.
Export period data
export = await engine.export_period("llm_usage", "acme_corp",
period_start=datetime(2026, 3, 1, tzinfo=UTC),
period_end=datetime(2026, 4, 1, tzinfo=UTC))
for dim_name, result in export.dimensions.items():
print(f" {dim_name}: {result.value_sum:,.0f} {result.unit}")MeteringMiddleware
Auto-records multi-dimensional events per HTTP request:
from hyperdjango.metering import MeteringMiddleware
def extract_llm_dims(request, response) -> dict[str, float]:
"""Extract dimensions from request/response."""
return {
"requests": 1,
"tokens_in": int(response.headers.get("x-tokens-in", 0)),
"tokens_out": int(response.headers.get("x-tokens-out", 0)),
"bytes_transferred": len(response.body) if hasattr(response, "body") else 0,
}
app.use(MeteringMiddleware(
engine=engine,
meter_name="llm_usage",
account_resolver=lambda r: str(r.user.id) if r.user else None,
dimension_extractor=extract_llm_dims,
quota_enforced=True,
))Default behavior
Without dimension_extractor, records
{"requests": 1} per request. Without
account_resolver, extracts from
request.user.tenant_id or request.user.id.
Maintenance
Rebuild aggregates
If aggregates get corrupted or schema changes, rebuild from raw events:
count = await engine.reaggregate("llm_usage",
start=datetime(2026, 3, 1, tzinfo=UTC),
end=datetime(2026, 4, 1, tzinfo=UTC))
print(f"Rebuilt {count} aggregate rows")Cleanup old data
deleted = await engine.cleanup(
retain_events_days=90, # keep raw events for 90 days
retain_aggregates_days=730, # keep aggregates for 2 years
)Admin Integration
from hyperdjango.metering import register_metering_admin
register_metering_admin(admin)
# Registers: Meter, MeterDimension, MeterAccount, MeterQuota, MeterAggregate
# With search, filter, readonly fields for audit trailData Model
7 fully relational tables (no JSON blobs):
| Table | Purpose | Key columns |
|---|---|---|
hyper_meters |
Meter definitions | name, description, is_active |
hyper_meter_dimensions |
Dimension specs per meter | meter_id FK, name, type, unit, agg |
hyper_meter_events |
Raw event log | meter_id FK, account_id, timestamp |
hyper_meter_event_values |
Dimension values per event | event_id FK, dimension_id FK, value |
hyper_meter_aggregates |
Pre-computed time buckets | meter_id, dimension_id, account_id, bucket_size, bucket_start, sum/count/min/max/last |
hyper_meter_accounts |
Account registry | account_id, type, tier, parent_account_id |
hyper_meter_quotas |
Usage limits | account_id, dimension_id, period, limit, action |
All tables use proper FK relationships. Admin can CRUD all models with inline editing.
API Reference
MeterEngine
| Method | Description |
|---|---|
ensure_tables() |
Create all 7 tables + indexes |
define_meter(name, dimensions, description) |
Define a meter with dimensions |
record(meter_name, account_id, dimensions, tenant_id, idempotency_key) |
Record a multi-dimensional event |
query(meter_name, account_id, dimension_name, period, start, end) |
Query single dimension |
query_multi(meter_name, account_id, dimension_names, period, start, end) |
Query multiple dimensions |
query_hierarchy(meter_name, root_account_id, dimension_name, period, start, end) |
Query across account hierarchy |
check_quota(account_id, meter_name, dimension_name, period) |
Check quota |
set_quota(account_id, meter_name, dimension_name, period, limit, action) |
Set quota |
export_period(meter_name, account_id, period_start, period_end) |
Export for billing |
reaggregate(meter_name, start, end) |
Rebuild aggregates from events |
cleanup(retain_events_days, retain_aggregates_days) |
Delete old data |
register_hook(hook) |
Register downstream consumer |
MeterHook
| Method | Description |
|---|---|
on_event(ctx) |
Called for every recorded event |
on_quota_exceeded(ctx, decision) |
Called when quota check fails |
on_period_close(export) |
Called when exporting a period |
Advanced ORM Query Building Cache
Compiled SQL Cache
HyperDjango’s ORM caches the SQL string itself, not just the result rows. Once a query shape has been compiled once, every subsequent call with the same structure skips WhereNode tree construction entirely — it just collects bind values and returns the cached template.
Result: 520,000 qps on cache hits, ~2× faster than re-compiling.
Request lifecycle
User.objects.filter(active=True).order_by("-id")[:20]
│
▼
┌───────────────────────────────┐
│ _build_select() entry │
│ in hyperdjango/query.py │
└───────────────┬───────────────┘
│
▼
┌────────────────────────────────────────┐
│ Cacheable? │
│ ✗ Expression annotations │── NO ──┐
│ ✗ Exists / NotExists subqueries │ │
│ ✗ with_cte() clauses │ │
└────────────────┬───────────────────────┘ │
│ YES │
▼ │
┌────────────────────────────────────────┐ │
│ _fast_cache_key(meta) │ │
│ ┌──────────────────────────────────┐ │ │
│ │ id(meta) │ │ │
│ │ _fast_where_key() ──► Zig FNV-1a│ │ │
│ │ ordering tuple │ │ │
│ │ limit │ │ │
│ └──────────────────────────────────┘ │ │
│ 4-tuple for ~90% of queries, │ │
│ 11-tuple for complex shapes │ │
└────────────────┬───────────────────────┘ │
│ │
▼ │
┌────────────────────────────────────────┐ │
│ _compiled_sql_cache.get(key) │ │
│ (lock-free dict.get) │ │
└─────────┬────────────────┬─────────────┘ │
│ │ │
HIT │ │ MISS │
│ │ │
▼ ▼ ▼
┌─────────────────────────┐ ┌────────────────────────────────┐
│ _collect_where_params() │ │ _build_where_tree() │
│ │ │ → WhereNode tree │
│ Walks filters/excludes │ │ .compile(start_idx) │
│ tuple list directly. │ │ → SQL fragment + params │
│ Zero tree alloc. │ │ │
│ Inlined passthrough │ │ Assemble parts: │
│ for exact/gt/gte/lt/ │ │ SELECT … FROM … JOIN … │
│ lte/iexact/regex. │ │ WHERE … GROUP BY … │
│ │ │ ORDER BY … LIMIT … OFFSET … │
└────────────┬────────────┘ └────────────────┬───────────────┘
│ │
│ ▼
│ ┌──────────────────────────────────┐
│ │ with _compiled_cache_lock: │
│ │ _compiled_sql_cache[key] = sql │
│ └────────────────┬─────────────────┘
│ │
└────────────────┬──────────────┘
│
▼
┌──────────────────────────────┐
│ return (sql_template, │
│ bind_params) │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ pg.zig prepared statement │
│ cache + execute │
└──────────────────────────────┘
The HIT path is the hot path. After the first call to any given query
shape, every subsequent call short-circuits at the
dict.get() and feeds straight into the pg.zig
prepared-statement cache. No tree, no string assembly, no allocator
churn.
Two caches, two purposes
HyperDjango ships two independent caches along the query path. They look related but live in different files and solve different problems:
Query execution
│
┌─────────────────────────────┴─────────────────────────────┐
│ │
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ Compiled SQL Cache │ │ Query Result Cache │
│ hyperdjango/query.py │ │ hyperdjango/query_cache. │
│ │ │ py │
│ Stores: SQL template │ │ │
│ strings │ │ Stores: row results │
│ │ │ │
│ Keyed by: │ │ Keyed by: │
│ • Model identity │ │ • Table name │
│ • Query structure │ │ • Full SQL text │
│ • FNV-1a where hash │ │ • Bind values │
│ │ │ │
│ Invalidated: │ │ Invalidated: │
│ • Never (templates are │ │ • post_save / post_delete│
│ pure functions of │ │ signals │
│ query structure) │ │ • FK dependency cascade │
│ • Process restart only │ │ • Meta.cache_ttl expiry │
│ │ │ │
│ Eviction: │ │ Eviction: │
│ • None — bounded by │ │ • LRU + TTL │
│ schema cardinality │ │ │
└───────────────────────────┘ └───────────────────────────┘
│ │
└─────────────────────────────┬─────────────────────────────┘
▼
┌──────────────────────────┐
│ pg.zig prepared stmts │
│ Cached parse plans │
└──────────────────────────┘
The compiled SQL cache is essentially free to keep forever — the same query shape always produces the same SQL text, regardless of model data. The result cache needs invalidation; the template cache never does.
Cache key fingerprint
_fast_cache_key(meta) produces one of two tuple shapes.
Different lengths never collide in dict lookup, so the dispatcher is
implicit:
┌────────────────────────────────────┐
│ Common case? │
│ no values/only/defer/annotations/ │
│ group_by/select_related/distinct/ │
│ for_update + no offset │
└────────────┬───────────────────────┘
│
┌──────────┴──────────┐
│ YES │ NO
▼ ▼
┌─────────────────────────┐ ┌────────────────────────────┐
│ Compact 4-tuple │ │ Full 11-tuple │
│ ┌─────────────────────┐ │ │ ┌────────────────────────┐ │
│ │ id(meta) │ │ │ │ id(meta) │ │
│ │ _fast_where_key() │ │ │ │ col_key (v/o/d) │ │
│ │ ordering │ │ │ │ select_related │ │
│ │ limit │ │ │ │ _fast_where_key() │ │
│ └─────────────────────┘ │ │ │ annotation key │ │
│ │ │ │ ordering │ │
│ ~90% of queries │ │ │ limit │ │
│ in production hit │ │ │ offset │ │
│ this path │ │ │ distinct │ │
│ │ │ │ for_update │ │
└─────────────────────────┘ │ │ group_by key │ │
│ └────────────────────────┘ │
└────────────────────────────┘
_fast_where_key()
— the inner fingerprint
The WHERE clause is the most structurally complex part. Hashing it well is what makes the fast path fast:
┌────────────────────────────────────┐
│ Filter tree contains Q objects │
│ or raw WHERE fragments? │
└────────────┬───────────────────────┘
│
┌──────────┴──────────┐
│ NO │ YES
▼ ▼
┌─────────────────────────┐ ┌────────────────────────────┐
│ Zig native FNV-1a hash │ │ Python tuple traversal │
│ │ │ │
│ _where_cache_key( │ │ Recursive Q._structural_ │
│ filters, excludes) │ │ key() walk + value shape │
│ │ │ extraction │
│ • METH_FASTCALL │ │ │
│ • Iterates tuples │ │ Slower path, but correct │
│ directly │ │ for arbitrarily nested │
│ • Zero list alloc │ │ AND/OR/NOT compositions │
│ • Returns u64 │ │ │
└─────────────────────────┘ └────────────────────────────┘
The native hash is one FFI call. The Python path is only taken when Q-objects or raw SQL fragments are in play.
Skip conditions — when the cache is intentionally bypassed
Three query shapes cannot be cached because they embed per-call SQL fragments or parameter positions that vary between invocations:
┌──────────────────────────────────────────────────────────────────────────┐
│ 1. Expression annotations │
│ .annotate(rank=SearchRank(...)) │
│ .annotate(avg=Avg("score")) │
│ │
│ Why skip: expressions emit their own SQL fragments with their │
│ own bind params. Template would not be reusable. │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ 2. Exists / NotExists correlated subqueries │
│ .filter(Exists(Other.objects.filter(parent_id=OuterRef("id")))) │
│ │
│ Why skip: OuterRef substitution uses per-call nonce sentinels │
│ (128 bits of entropy) so the same logical query produces │
│ different SQL on every call. │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ 3. with_cte() clauses │
│ .with_cte("ancestors", recursive_body_sql, *params, recursive=True) │
│ │
│ Why skip: CTE params interleave into the WITH prefix and {idx} │
│ placeholders renumber on every call. Template wouldn't │
│ match the param positions. │
└──────────────────────────────────────────────────────────────────────────┘
These all fall back to the full WhereNode tree compile path, which still works correctly — just without the cache speedup. Anything that doesn’t hit one of these three conditions is automatically cached.
Coverage across builders
All four SQL builders share the same _compiled_sql_cache
storage (COUNT gets its own _compiled_count_cache keyspace
to avoid key collisions):
┌──────────────────┬────────────────────────┬────────────────────────┐
│ Builder │ Cache key │ Skip condition │
├──────────────────┼────────────────────────┼────────────────────────┤
│ _build_select │ _fast_cache_key(meta) │ Expressions / Exists / │
│ │ │ with_cte │
├──────────────────┼────────────────────────┼────────────────────────┤
│ _build_count │ (id(meta), │ (same as select) │
│ │ _fast_where_key()) │ │
├──────────────────┼────────────────────────┼────────────────────────┤
│ _build_update │ (id(meta), "U", │ F() in SET values │
│ │ set_cols, │ │
│ │ where_key, │ │
│ │ returning_cols) │ │
├──────────────────┼────────────────────────┼────────────────────────┤
│ _build_delete │ (id(meta), "D", │ (none — always cached) │
│ │ where_key) │ │
└──────────────────┴────────────────────────┴────────────────────────┘
The discriminator strings "U" and "D"
ensure UPDATE/DELETE keys never collide with SELECT keys for the same
model and WHERE shape.
Free-threading and concurrency
The cache is built for Python 3.14t (GIL disabled). Three properties make this safe under contention:
┌─────────────────────────────────────────────────┐
│ Reads (cache HIT path) │
│ ┌───────────────────────────────────────────┐ │
│ │ _compiled_sql_cache.get(key) │ │
│ │ Lock-free. dict.get is atomic in CPython │ │
│ │ even under free-threading. │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Writes (cache MISS → store) │
│ ┌───────────────────────────────────────────┐ │
│ │ with _compiled_cache_lock: │ │
│ │ _compiled_sql_cache[key] = sql │ │
│ │ │ │
│ │ Two threads computing the same MISS in │ │
│ │ parallel will both write the SAME sql │ │
│ │ string. Last write wins. Idempotent. │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Key construction │
│ ┌───────────────────────────────────────────┐ │
│ │ _fast_cache_key is a pure function of │ │
│ │ the QuerySet's frozen state at the │ │
│ │ moment _build_select is called. │ │
│ │ │ │
│ │ QuerySet is cloned on every chain step, │ │
│ │ so no shared mutable state. │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Validated by an 8-thread × 1000-iteration concurrent stress test in
scripts/test_free_threading_stress.py.
Measured performance
| Path | Throughput | Notes |
|---|---|---|
| Cache HIT | 520K qps | dict.get + _collect_where_params walk |
| Cache MISS | ~260K qps | Full WhereNode tree compile + SQL assembly |
| Speedup | ~2.0× | Per-query, end-to-end SQL build cost |
| FFI hash call | ~280 ns | _where_cache_key Zig FNV-1a (METH_FASTCALL) |
Numbers from scripts/bench_where_compile.py (multi-run
median, jitter < 5%, ReleaseFast Zig build).
In production, the HIT path takes the per-call SQL-build cost from microseconds to nanoseconds, leaving the database round-trip as the dominant remaining cost. That’s where you want it.
postgres support
We ONLY support postgres in hyperdjango as the OLTP database of choice. No others.
Postgres types map to python types cleanly when read natively
| PostgreSQL Type | OID | Python Type | Format | Notes |
|---|---|---|---|---|
smallint / int2 |
21 | int |
Binary | 2-byte big-endian |
integer / int4 |
23 | int |
Binary | 4-byte big-endian |
bigint / int8 |
20 | int |
Binary | 8-byte big-endian |
real / float4 |
700 | float |
Binary | IEEE 754 single |
double precision / float8 |
701 | float |
Binary | IEEE 754 double |
boolean |
16 | bool |
Binary | 1 byte (0/1) |
text |
25 | str |
Direct | UTF-8 passthrough |
varchar / character varying |
1043 | str |
Direct | UTF-8 passthrough |
char / character |
1042 | str |
Direct | Padded with spaces |
name |
19 | str |
Direct | System identifier type |
"char" |
18 | str |
Direct | Single-byte internal type |
numeric / decimal |
1700 | decimal.Decimal |
Binary | Base-10000 digit decoding |
uuid |
2950 | uuid.UUID |
Binary | 16-byte → UUID object |
timestamp |
1114 | datetime.datetime |
Binary | 8-byte microseconds from PG epoch |
timestamptz |
1184 | datetime.datetime |
Binary | 8-byte microseconds from PG epoch |
date |
1082 | datetime.date |
Binary | 4-byte days from PG epoch |
time |
1083 | datetime.time |
Binary | 8-byte microseconds from midnight |
timetz |
1266 | datetime.time (with tzinfo) |
Binary | 8-byte usec + 4-byte tz offset |
interval |
1186 | datetime.timedelta |
Binary | 8-byte usec + 4-byte days + 4-byte months |
bytea |
17 | bytes |
Direct | Raw binary data |
json |
114 | dict / list / str /
int / float / bool /
None |
SIMD JSON | Native SIMD parser (2-10x faster than json.loads) |
jsonb |
3802 | dict / list / str /
int / float / bool /
None |
SIMD JSON | Skip version byte, SIMD parse |
inet |
869 | ipaddress.IPv4Address / IPv6Address |
Binary | 4/16-byte address + family |
cidr |
650 | ipaddress.IPv4Network / IPv6Network |
Binary | Address + mask bits |
money |
790 | decimal.Decimal
(e.g. Decimal("1234.56")) |
Binary | 8-byte cents → exact Decimal (no float rounding) |
bit |
1560 | int (e.g. 179 for
B'10110011') |
Binary | Bit extraction → Python int (supports bitwise ops) |
varbit / bit varying |
1562 | int (e.g. 10 for
B'1010') |
Binary | Variable-length → Python int |
xml |
142 | str |
Direct | XML text passthrough |
pg_lsn |
3220 | str (e.g. "16/B374D848") |
Binary | 8-byte → hex format |
tsvector |
3614 | list[tuple[str, list[int]]] |
Binary | Native binary parse: [("lexeme", [pos, ...]), ...] |
tsquery |
3615 | str (e.g. "'fat' & 'cat'") |
Binary | Native binary tree → text reconstruction |
Postgres array types map to native python arrays too
| PostgreSQL Type | OID | Python Type | Element Conversion |
|---|---|---|---|
smallint[] |
1005 | list[int] |
Binary int2 |
integer[] |
1007 | list[int] |
Binary int4 |
bigint[] |
1016 | list[int] |
Binary int8 |
real[] |
1021 | list[float] |
Binary float4 → float64 |
double precision[] |
1022 | list[float] |
Binary float8 |
boolean[] |
1000 | list[bool] |
Binary 1-byte |
text[] |
1009 | list[str] |
Direct UTF-8 |
varchar[] |
1015 | list[str] |
Direct UTF-8 |
name[] |
1003 | list[str] |
Direct UTF-8 |
timestamp[] |
1115 | list[datetime] |
Binary 8-byte usec |
timestamptz[] |
1185 | list[datetime] |
Binary 8-byte usec |
date[] |
1182 | list[date] |
Binary 4-byte days |
time[] |
1183 | list[time] |
Binary 8-byte usec |
numeric[] |
1231 | list[Decimal] |
Binary base-10000 |
uuid[] |
2951 | list[UUID] |
Binary 16-byte |
bytea[] |
1001 | list[bytes] |
Direct binary |
jsonb[] |
3807 | list[dict/list/...] |
SIMD JSON parse per element |
json[] |
199 | list[dict/list/...] |
SIMD JSON parse per element |
oid[] |
1028 | list[int] |
Binary int4 |
Docs
Docs can be browsed at hyperdjango hyperdocs hypersite as well.