Performance
Practical guidance for squeezing the most throughput out of a Kirak application.
Connection Pool Tuning
Section titled “Connection Pool Tuning”The connection pool is the most impactful performance lever.
"database": { "pool_min": 5, "pool_max": 20, "pool_recycle_seconds": 3600 }| Setting | Runtime Default | Recommended Production | Guidance |
|---|---|---|---|
database.pool_min |
1 |
5 |
Minimum idle connections kept alive. Set to the average concurrent DB demand of your API. |
database.pool_max |
10 |
20 |
Hard ceiling. Must be <= max_connections on the DB server divided by number of API worker processes. |
database.pool_recycle_seconds |
3600 |
3600 |
Recycle connections every N seconds. Lower this (e.g. 600) if your DB or network terminates idle connections. |
Sizing formula (per worker process):
pool_max <= floor(db_max_connections / num_worker_processes) - reserved_connectionsFor example, MySQL with max_connections=151, 4 workers, 11 reserved:
pool_max <= floor((151 - 11) / 4) = 35Worker count: Use uvicorn --workers N where N = 2 x CPU_cores + 1 for I/O-bound workloads. Each worker is a separate process with its own pool.
Uvicorn Worker Configuration
Section titled “Uvicorn Worker Configuration”# Productionuvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 --loop uvloop
# Developmentuvicorn main:app --host 0.0.0.0 --port 8000 --reloaduvloop (if installed) replaces asyncio’s default event loop with a faster libuv-based one:
pip install uvloopWith Gunicorn as the process manager:
gunicorn main:app \ -w 4 \ -k uvicorn.workers.UvicornWorker \ --bind 0.0.0.0:8000 \ --timeout 30Bulk Operations
Section titled “Bulk Operations”Single-record loops are the most common performance mistake in Kirak apps. Use bulk operations instead.
Bulk create
Section titled “Bulk create”# Slow: 1000 INSERT statementsfor record in records: await kirak.create("orders", {"data": record})
# Fast: ~10 INSERT statements (chunk_size=100 default)await kirak.create("orders", {"data": records})By default, Kirak sends all rows in one chunk and (on MySQL) one INSERT statement – there is no hardcoded 500/100 default. chunk_size and batch_size are opt-in tuning knobs for very large batches:
await kirak.create("orders", { "data": records, "batch_size": 1000, # rows per INSERT VALUES (...) -- default: all rows in one statement (MySQL) "chunk_size": 500, # rows per memory chunk -- default: all rows in one chunk})For PostgreSQL, batch_size is always capped at 32767 // num_columns to stay within the parameter limit, regardless of what you pass. For large batches (tens of thousands of rows), setting an explicit chunk_size avoids holding the whole batch in memory at once even though there’s no default cap.
Bulk update
Section titled “Bulk update”await kirak.update("orders", { "where": {"status": "pending"}, "data": {"status": "processing"}})A single UPDATE with a WHERE clause is always faster than fetching and updating record-by-record.
Avoiding N+1 Queries
Section titled “Avoiding N+1 Queries”Kirak does not have an ORM-style lazy loader, which means there is no automatic N+1 risk. However, the same pattern can emerge in hook code:
# Slow: one query per user@kirak.on("orders").hook("after_fetch")async def enrich_orders(result): for order in result["data"]: user = await kirak.fetch("users", {"query": {"id": order["user_id"]}}) order["user"] = user["data"][0] return result
# Fast: one query total@kirak.on("orders").hook("after_fetch")async def enrich_orders(result): user_ids = list({o["user_id"] for o in result["data"]}) users = await kirak.fetch("users", { "query": {"id__in": user_ids}, "limit": len(user_ids), }) user_map = {u["id"]: u for u in users["data"]} for order in result["data"]: order["user"] = user_map.get(order["user_id"]) return resultPagination
Section titled “Pagination”Always paginate large result sets. The default limit is 10; the maximum is whatever max_limit is set to in the model schema.
Offset-based pagination degrades on large tables because OFFSET 10000 LIMIT 10 still scans 10,010 rows:
# Degraded on large tablesawait kirak.fetch("events", {"page": 1001, "limit": 10})
# Better: use a filter on the last-seen primary key (cursor pagination)await kirak.fetch("events", { "query": {"id__gt": last_seen_id}, "limit": 10, "order_by": "id", "order": "ASC",})Set a sensible max_limit in your model schema to prevent clients from accidentally requesting massive result sets:
{ "events": { "table": "events", "max_limit": 100, "schema": { "..." } }}select_fields
Section titled “select_fields”Avoid fetching columns you don’t need:
# Returns all fields (~30 columns)await kirak.fetch("users", {"limit": 100})
# Returns only what the caller needsawait kirak.fetch("users", { "limit": 100, "select_fields": ["id", "email", "role"]})This reduces both database I/O and serialization overhead.
Hook Performance
Section titled “Hook Performance”Hooks execute serially within the dispatch pipeline. Long-running hooks block the response.
Rules:
- Keep synchronous computation in hooks fast (< 1 ms).
- Move slow operations (external API calls, email sends, file writes) to after-hooks where the response is already formed, or better, to background tasks.
- The asyncio timeout is
hook_timeout_seconds(kirak.json, default 5.0) seconds per hook. A hook that exceeds this is skipped and logged – it does not crash the request.
# Good: fire-and-forget slow workfrom fastapi import BackgroundTasks
@kirak.on("orders").hook("after_create")async def notify_after_create(result): order_id = result.get("data", {}).get("id") if order_id: # Don't await this -- add to background queue instead import asyncio asyncio.create_task(send_order_confirmation(order_id)) return resultRedis for Rate Limiting and Blacklist
Section titled “Redis for Rate Limiting and Blacklist”Rate limiting and token blacklist lookups happen on every authenticated request. Without Redis, these hit the database – two extra queries per request.
With Redis (KIRAK_REDIS_URL), both operations are sub-millisecond key lookups. For high-traffic APIs this is significant:
- DB blacklist: ~1-5 ms per request
- Redis blacklist: ~0.1-0.5 ms per request
See Redis Integration for setup.
Scheduler Worker Concurrency
Section titled “Scheduler Worker Concurrency”The scheduler worker starts automatically when "scheduler" is in modules – each uvicorn/gunicorn process runs its own worker. To increase parallelism, add more application worker processes:
# 4 application processes, each with its own embedded scheduler workergunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
# Or with uvicorn (multiple processes via --workers)uvicorn main:app --workers 4Each worker process polls the queue independently. Use the Redis or RabbitMQ provider for multi-worker deployments to avoid duplicate job execution. The provider choice itself is a kirak.json manifest setting, not an environment variable – only the connection URL is:
{ "scheduler": { "default_provider": "redis", "providers": { "redis": { "type": "redis" } } }}KIRAK_SCHEDULER_REDIS_URL=redis://localhost:6379For background work that has variable load, the Redis backend with multiple workers is strongly preferred over the database backend.
Index Guidance
Section titled “Index Guidance”Kirak generates tables but does not generate indexes beyond primary keys and unique constraints. Add indexes manually for fields that appear in frequent filter clauses:
-- Add in a migration file-- For MySQL:CREATE INDEX idx_orders_user_status ON orders (user_id, status);
-- For PostgreSQL:CREATE INDEX idx_orders_user_status ON orders (user_id, status);CREATE INDEX idx_orders_created_at ON orders (created_at DESC);Common candidates:
- Foreign key fields (
user_id,organization_id) - Status/type enum fields combined with a date
- Fields used in
order_byclauses on large tables deleted_aton soft-delete tables (partial index wheredeleted_at IS NULL)