Skip to content

Performance

Practical guidance for squeezing the most throughput out of a Kirak application.


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_connections

For example, MySQL with max_connections=151, 4 workers, 11 reserved:

pool_max <= floor((151 - 11) / 4) = 35

Worker 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.


Terminal window
# Production
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 --loop uvloop
# Development
uvicorn main:app --host 0.0.0.0 --port 8000 --reload

uvloop (if installed) replaces asyncio’s default event loop with a faster libuv-based one:

Terminal window
pip install uvloop

With Gunicorn as the process manager:

Terminal window
gunicorn main:app \
-w 4 \
-k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 30

Single-record loops are the most common performance mistake in Kirak apps. Use bulk operations instead.

# Slow: 1000 INSERT statements
for 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.

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.


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 result

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 tables
await 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": { "..." }
}
}

Avoid fetching columns you don’t need:

# Returns all fields (~30 columns)
await kirak.fetch("users", {"limit": 100})
# Returns only what the caller needs
await kirak.fetch("users", {
"limit": 100,
"select_fields": ["id", "email", "role"]
})

This reduces both database I/O and serialization overhead.


Hooks execute serially within the dispatch pipeline. Long-running hooks block the response.

Rules:

  1. Keep synchronous computation in hooks fast (< 1 ms).
  2. Move slow operations (external API calls, email sends, file writes) to after-hooks where the response is already formed, or better, to background tasks.
  3. 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 work
from 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 result

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.


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:

Terminal window
# 4 application processes, each with its own embedded scheduler worker
gunicorn 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 4

Each 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:6379

For background work that has variable load, the Redis backend with multiple workers is strongly preferred over the database backend.


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_by clauses on large tables
  • deleted_at on soft-delete tables (partial index where deleted_at IS NULL)