At LiteLLM v1.100.0 the field maximum_spend_logs_retention_period defaults to None, and the cleanup job is only registered with the scheduler when that key or one of its two siblings is set, so a proxy running for a year has deleted nothing. The write side defaults on: disable_spend_logs is False at module scope, and it guards three calls, the spend log insert, the tool usage transaction and the auto-router turn transaction, while the Key, User and Team spend updates dispatch outside that branch and keep working either way. Once retention is on, a run is bounded by four settings whose defaults are 1000 rows per DELETE statement, 500 statements per table per run, a 5m wall-clock budget shared across tables, and a 30s statement and lock timeout on each batch. That bounding was merged on 2026-08-14 and first shipped in the stable line as v1.98.0 on 2026-08-23, replacing a path where one run could delete 500,000 rows per table with nothing capping how long it held the database. The job prunes four tables, and two of them are never touched unless you set their own key. Where deletion cannot keep pace, the project ships an opt-in Postgres range partitioning script whose header reports the table seen at 450GB+ after roughly a month at high request volume. Before changing any of it, measure the heap, the index total and the dead tuple count, because those three numbers point at different fixes.
If your LiteLLM spend logs table is too large, nothing has gone wrong. The proxy writes a durable row to LiteLLM_SpendLogs for every request it serves, and at v1.100.0 it deletes none of them until you tell it to. The retention field is declared as maximum_spend_logs_retention_period: str | None = Field(None, description="Maximum retention period for spend logs (e.g., '7d' for 7 days). Logs older than this will be deleted."), and the default is None. The cleanup job is not merely idle in that state. It is never registered with the scheduler at all.
The project's own conversion script puts a number on the tail of the distribution. Its header states that at high request volume, retention via DELETE leaves dead tuples that autovacuum cannot reclaim quickly enough, so the table keeps growing on disk, seen at 450GB+ after roughly a month. That is a comment in db_scripts/partition_spend_logs.sql with no request rate, row width or workload attached to it, so read it as the shape of the failure rather than a projection for your install. Your own number depends heavily on five JSON columns whose width is decided by whether the deployment logs payloads.
This post is the storage half of the problem. Getting cost data into the table in the first place, call-site tagging and the per-tenant rollup, is covered separately, and this post assumes you already have tagged rows. Tier selection between gateways is its own decision; here you already run LiteLLM. Every default below was read against v1.100.0, the stable release published on 2026-09-06.
Nothing has been deleted because no job was ever scheduled
The condition in proxy_server.py is worth reading before you touch any other setting. The cleanup job is added to the scheduler inside a single if, and that if tests three keys: maximum_spend_logs_retention_period, maximum_autorouter_session_retention_period and maximum_health_check_retention_period. With none of the three present in general_settings, no job exists. That is why tuning the batch size or the run budget on a growing table changes nothing: those bounds describe a run that is never scheduled.
The write side defaults the other way. disable_spend_logs = False sits at module scope and is then overridden by general_settings.get("disable_spend_logs", disable_spend_logs), so a fresh install starts accumulating rows on the first request. The two defaults combine into the state on disk: writes on from day one, deletion off until someone configures it, and a table whose oldest row is as old as the deployment.
One thing you do not have to check: none of this is gated. Searching the cleanup module at v1.100.0 for premium, license and enterprise returns nothing, so the retention path carries no license check. For an air-gapped or on-premise install that is the difference between a feature you can use and a feature that phones home before it runs.
Measure the table before you delete a row
A disk alert collapses several different problems into one number. Separate them first. These are standard Postgres catalog functions rather than a LiteLLM interface, so they work regardless of proxy version.
SELECT
pg_size_pretty(pg_total_relation_size('"LiteLLM_SpendLogs"')) AS total,
pg_size_pretty(pg_relation_size('"LiteLLM_SpendLogs"')) AS heap,
pg_size_pretty(pg_indexes_size('"LiteLLM_SpendLogs"')) AS indexes;
SELECT n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'LiteLLM_SpendLogs';
SELECT min("startTime"), max("startTime"), count(*)
FROM "LiteLLM_SpendLogs";Four readings, and the table below is how to act on each.
The index row deserves a note. The schema declares four indexes on this table: startTime, the composite startTime, request_id, end_user and session_id, with request_id as the primary key. Dropping one diverges your database from the migration LiteLLM applies on startup, which is a maintenance liability for a small and temporary win. The lever that works on index size is the same lever that works on heap size: fewer rows.
Row width is the other half. metadata, request_tags, messages, response and proxy_server_request are JSON columns defaulting to "{}" or "[]", so an install that logs payloads stores a copy of the conversation in every row and one that does not stores something closer to a metrics record. No bytes-per-row figure is worth quoting at you: measure pg_total_relation_size at two points a week apart and divide by the request count between them.
| Reading | What it means | What to change |
|---|---|---|
Heap much larger than indexes, n_dead_tup low | Row volume, and nothing has ever deleted | Set a retention window |
| Indexes a large share of the total | Four declared indexes carry a per-row cost | Still fewer rows: the indexes are part of the schema LiteLLM migrates |
n_dead_tup high, last_autovacuum stale | Deletion already ran and autovacuum is behind it | Partitioning, not a larger batch size |
min("startTime") older than your stated window | No cutoff is in effect | Confirm the key is set and parsed, then read the cleanup metrics |
disable_spend_logs and exactly what it costs
The fastest way to stop the growth is to stop the writes, and the flag that does it is blunter than its name suggests. In db_spend_update_writer.py at v1.100.0, if disable_spend_logs is False: guards three calls: _insert_spend_log_to_db, _enqueue_tool_usage_transaction and _enqueue_autorouter_turn_transaction. Turn the flag on and all three stop. You lose the per-request row, the tool index row and the auto-router session rollup together.
What survives is the part that decides whether the flag is usable. _batch_database_updates(...) is dispatched after the if/else, outside the branch, so it runs in both states. The else branch says so in its own log line: disable_spend_logs=True. Skipping writing spend logs to db. Other spend updates - Key/User/Team table will still occur. Key, user and team spend counters keep updating, which means budgets and key-level spend enforcement keep working. Turning off spend logs does not turn off your budgets.
general_settings: disable_spend_logs: true # no per-request rows, no tool index rows, no auto-router rollups disable_error_logs: true # keeps LLM exceptions out of the database under sustained provider errors
The second key is worth setting at the same time. LiteLLM writes LLM exceptions to the database by default, and sustained provider errors are the documented case where that bloats the spend logs table. Send those exceptions to your logging stack instead.
Two caveats on this pair. It is a headroom purchase rather than retention: every row already on disk stays until something deletes it. And you are giving up the forensic trail that makes a cost spike diagnosable, which is the evidence base the overnight bill spike diagnostic runs on. The defensible version of this configuration is temporary: disable the writes to buy a weekend, set retention, re-enable them.
The key that turns deletion on, and the four bounds around it
maximum_spend_logs_retention_period is the only setting that switches deletion on for spend logs, and every other cleanup setting is a bound on the run it enables. Quote the value as a string. Retention values are parsed by duration_in_seconds, and a bare integer is coerced with the warning %s is an integer (%s); treating as days. Use a string like '3d' to be explicit. A value the parser cannot read logs Invalid %s value: %s, error: %s and returns None, which means no deletion for that table and no error anywhere else. A typo in a retention window fails quietly and looks exactly like a table that is being pruned correctly until you check min("startTime").
Scheduling has two paths. maximum_spend_logs_cleanup_cron is passed to CronTrigger.from_crontab when set; otherwise retention_interval falls back to general_settings.get("maximum_spend_logs_retention_interval", "1d") and the job is added with seconds=interval_seconds + random.randint(0, 60). So the interval path carries up to 60 seconds of jitter and the cron path carries none, which matters if several replicas are on the same cron and no distributed lock is configured. Neither of those two scheduling keys is a declared field in the Pydantic settings model at v1.100.0: both are read with general_settings.get(). A misspelled key name is not rejected at startup, and an invalid cron expression leaves the job unscheduled with only a log line to show for it.
Each of those four falls back to an environment variable with the same default, in table order: SPEND_LOG_CLEANUP_BATCH_SIZE at 1000, SPEND_LOG_RUN_LOOPS at 500, SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS at 300 and SPEND_LOG_CLEANUP_BATCH_TIMEOUT_SECONDS at 30. Three further environment variables cover failure handling and the backlog probe: SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES at 3, SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS at 0.5 and SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP at 100000.
Here is the whole surface in one place.
general_settings: # Nothing is deleted until at least one retention period is set. maximum_spend_logs_retention_period: "30d" # These two are separate cutoffs on separate tables. Unset means never deleted. maximum_autorouter_session_retention_period: "365d" maximum_health_check_retention_period: "30d" # Schedule. Cron wins when set; otherwise the interval applies, plus up to 60s of jitter. maximum_spend_logs_cleanup_cron: "0 4 * * *" # no jitter: every replica fires together unless the Redis lock is configured # maximum_spend_logs_retention_interval: "1d" # default when no cron is set # Bounds on one run. These are the defaults, shown so you can see what you are changing. maximum_spend_logs_cleanup_batch_size: 1000 maximum_spend_logs_cleanup_max_batches: 500 maximum_spend_logs_cleanup_run_budget: "5m" maximum_spend_logs_cleanup_batch_timeout: "30s"
The three windows above are values chosen for a hypothetical deployment, not defaults. Pick each one from an obligation you can name, which is the subject of the last section.
| Setting | Default at v1.100.0 | What it bounds |
|---|---|---|
maximum_spend_logs_cleanup_batch_size | 1000 | Rows deleted per DELETE statement |
maximum_spend_logs_cleanup_max_batches | 500 | DELETE statements per table per run |
maximum_spend_logs_cleanup_run_budget | 5m | Wall clock for one run, shared across every table it prunes |
maximum_spend_logs_cleanup_batch_timeout | 30s | Postgres statement_timeout and lock_timeout on each delete batch |
Why a cleanup run cannot saturate the database any more
The bounds are not decoration. A change merged on 2026-08-14, released in the stable line as v1.98.0 on 2026-08-23, carries the title perf(spend-logs): bound retention cleanup so one run cannot saturate the database, and its body lists four properties of the path it replaced: one cleanup run could delete 500,000 rows per table, unbounded in time; a single delete batch could hold row locks indefinitely; batch size and run length were environment-only and not reachable from the dashboard; and nothing reported what the cleanup job cost the database.
Every one of those four has a counterpart in the current job. The class docstring states it directly: every run is bounded so it can never monopolise the database, with a wall-clock budget shared across all tables, a per-table batch cap, and a Postgres statement and lock timeout on every statement the job issues, deletes and the outstanding-rows probe alike, and a run that hits a bound stops cleanly while the next run resumes from where it left off, because the cutoff is recomputed and deleted rows are gone.
The lock timeout is the part worth understanding before you widen anything. Each batch runs inside a transaction that first issues SET LOCAL statement_timeout and SET LOCAL lock_timeout, and the per-statement bound is the batch timeout or whatever is left of the run budget, whichever is smaller. Without those, as the code comment puts it, a single batch blocked behind a lock would hold its connection and the row locks it already took indefinitely. Raising maximum_spend_logs_cleanup_batch_timeout to get through a backlog raises exactly that exposure, which is why it is the last bound to touch rather than the first.
Reporting came with the same change, and it is what turns retention from a setting into something you can operate. Five metrics exist: litellm_spend_log_cleanup_rows_deleted_total, litellm_spend_log_cleanup_batch_duration_seconds, litellm_spend_log_cleanup_rows_remaining and litellm_spend_log_cleanup_batch_failures_total, all labelled by table, plus litellm_spend_log_cleanup_runs_total labelled by outcome. The outcome label takes six values: completed, budget_exhausted, batch_cap_reached, skipped_locked, skipped_disabled and aborted.
# Backlog that never drains: the run is bounded but ingest is faster than deletion. litellm_spend_log_cleanup_rows_remaining # Runs that keep hitting the wall clock rather than finishing. sum by (outcome) (rate(litellm_spend_log_cleanup_runs_total[1h])) # Deletion throughput per table. sum by (table) (rate(litellm_spend_log_cleanup_rows_deleted_total[1h]))
Read rows_remaining as a floor rather than a count. The probe is capped by SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP, which defaults to 100000, so a value pinned at that number means at least that many rows are outstanding and possibly far more. The signal that decides your next architecture step is the pair: a sustained budget_exhausted outcome together with a rows_remaining that never falls.
One more outcome value explains itself only if you know the locking model. Multi-replica safety comes from the pod lock manager, and the entry point's docstring says that if one is available it ensures only one pod runs cleanup, and that without one, cleanup runs with no distributed locking. The lock is attempted only when the pod lock manager and its Redis cache are both present, and a replica that loses the race records skipped_locked. Redis is not required for retention to work. Without it, every replica prunes concurrently, each within its own bounds, against one database.
When deletion stops keeping pace: native range partitioning
Past some request rate, bounded deletion is the wrong tool no matter how you tune it, and the limit is dead tuples rather than throughput. Autovacuum makes the space a deleted row leaves reusable inside the heap rather than handing it back to the operating system, which is the condition the project's script header describes when it reports the table seen at 450GB+ after roughly a month. The corresponding reading in your own database is a high n_dead_tup with a stale last_autovacuum.
Partitioning changes the operation instead of tuning it. With native Postgres range partitioning on startTime, retention drops whole partitions, which is instant and returns disk to the operating system immediately. LiteLLM ships the conversion as an opt-in manual operation: the default schema is not partitioned, and db_scripts/partition_spend_logs.sql converts it. Do not improvise the DDL; run theirs, on a maintenance window, with a restore tested first.
Three constraints come with the conversion, and each one can bite a deployment that treats it as a toggle.
request_id alone becomes ("request_id", "startTime"). LiteLLM's write path uses INSERT ... ON CONFLICT DO NOTHING, which is compatible with the composite key, so the proxy itself needs no change. Anything you wrote that joins or upserts on request_id alone does.prisma db push stops working. After partitioning, prisma db push, including the proxy's --use_prisma_db_push startup mode, is not supported: it tries to rewrite the primary key back to ("request_id"), which Postgres rejects on a partitioned table. The proxy detects this and exits with guidance. Startup has to use the default path, prisma migrate deploy.Once the script has run, two settings turn the partitioned path on and give it a cutoff.
general_settings: use_spend_logs_partitioning: true maximum_spend_logs_retention_period: "30d"
Partition granularity comes from SPEND_LOG_PARTITION_INTERVAL, which defaults to day, and lead time from SPEND_LOG_PARTITION_PRECREATE_AHEAD, which defaults to 7 and counts partitions rather than days, so the default pair keeps a week of daily partitions ahead. Keep the granularity consistent with the retention window you set: daily partitions against a 30d window means dropping one partition a day, which is the behaviour you converted for. Rollback is db_scripts/unpartition_spend_logs.sql.
The scope limit is worth stating plainly. Only LiteLLM_SpendLogs has a conversion script at v1.100.0. The other three tables the cleanup job prunes stay on batched deletion whatever you do here, which is fine, because they are not the tables that reached 450GB.
Four tables expire, and two of them only if you name them
The cleanup job prunes four tables, each on its own timestamp column.
The tool index is the well-behaved one. Its rows are keyed on ("request_id", "tool_name") and the code comment explains why it shares a cutoff: tool index rows are derived from spend logs, so they expire on the same cutoff, and rows older than retention point at already-deleted logs. Set the spend logs window and this table follows.
The other two do not inherit anything. LiteLLM_AutoRouterSession, keyed on ("api_key", "session_id", "router_name"), states in its own field description that rows whose last turn is older than the period are deleted by the spend log cleanup job on that job's schedule, and that unset means rollup rows are never deleted. The same wording applies to LiteLLM_HealthCheckTable, keyed on ("health_check_id",). A deployment that sets only the spend logs window prunes two of the four tables and leaves the other two unbounded beside them, which is a slower version of the problem you started with.
The health check table carries one extra instruction, and the direction it fails in is the harmful one. Its description says to set the window well above health_check_interval, because the health endpoint and the dashboard read the latest row per model. A retention window shorter than the interval can delete the only row a model has, and the symptom is a health view that goes blank rather than an error.
| Table | Timestamp column | Key that sets the cutoff | If that key is unset |
|---|---|---|---|
LiteLLM_SpendLogs | startTime | maximum_spend_logs_retention_period | No spend log deletion |
LiteLLM_SpendLogToolIndex | start_time | The same spend logs retention period | No deletion, it follows the spend logs cutoff |
LiteLLM_AutoRouterSession | last_turn_at | maximum_autorouter_session_retention_period | Rollup rows are never deleted |
LiteLLM_HealthCheckTable | checked_at | maximum_health_check_retention_period | Rows are never deleted |
Pick the window from the audit obligation, then size the budget to it
Everything above is reversible except the rows you delete, so the window is the one decision to make from outside the database. The audit obligation sets the floor, the cleanup budget is sized to fit it, and disk pressure gets a vote only after that. Where the rows carry request and response payloads, the GDPR framing for EU customer data is the right place to settle the duration before anyone edits a YAML file.
The move that makes a short window affordable is to stop treating the gateway database as the system of record. Export rows to a warehouse and keep Postgres to an operational window measured in weeks. The lifecycle timestamps merged on 2026-08-19 and first shipped in the stable line as v1.99.0 on 2026-09-01 exist for exactly that pipeline: the change body states that spend log exports lacked database ingestion timestamps and that incremental pipelines could not identify newly persisted rows. The table now carries created_at and updated_at alongside startTime. Checkpoint your export on created_at. startTime is request-side, so a row persisted late can land behind a window you already exported, and a row skipped that way is missing from the warehouse with nothing flagging it. A shorter gateway window with a warehouse behind it also keeps the chargeback process intact, since showback and chargeback read monthly aggregates rather than raw rows.
On-premise is where this stops being an operations chore. In a regulated deployment the weights and the traffic are inside your perimeter precisely so the conversation never leaves it, and then the gateway writes that same conversation into five JSON columns in a Postgres database that is in scope for every control the source system is in scope for. The spend logs table is a second copy of the prompts and responses, not telemetry about them. Retention on it is an access control and an erasure obligation, which is also why the absence of a license check in the cleanup path matters: an isolated network can run the whole retention path without an outbound call to anything.
The database role is the piece LiteLLM does not specify. The job issues SELECT and DELETE, sets statement_timeout and lock_timeout with SET LOCAL, and under partitioning it creates and drops tables. Derive the grant from that operation set rather than from an example, and keep it separate from the role the proxy uses for its normal write path. The privilege separation argument belongs to the LiteLLM proxy hardening checklist, which covers database privilege alongside the rest of the day-two surface; the rest of the gateway tooling set sits in the AI development tools pillar.
This week, in order. Run the three queries in the measurement section and write down the heap, the index total, n_dead_tup and min("startTime"). Set maximum_spend_logs_retention_period to a quoted window you can defend to an auditor, and set the auto-router and health check windows in the same change so the two silent tables are not left behind. Leave all four cleanup bounds at their defaults. Then, after the first scheduled run, read litellm_spend_log_cleanup_runs_total by outcome: completed means you are done, and budget_exhausted with a flat rows_remaining means the partitioning script is your next maintenance window rather than a bigger batch size.
FAQ
Quick answers to the questions this post tends to raise.
if disable_spend_logs is False: guards three calls: the per-request spend log insert, the tool usage transaction and the auto-router turn transaction. The batch database update that maintains the Key, User and Team spend tables is dispatched after that branch and runs either way, and the else branch says so in its log line, stating that other spend updates to the Key, User and Team tables will still occur. Budgets, key-level spend and team-level spend therefore keep working with the flag on. What you give up is the per-request row, the tool index row and the auto-router session rollup, which is the evidence a cost investigation reads. The flag also shrinks nothing: rows already on disk stay until retention deletes them.


