Query Plan Cache
Query Plan Cache
HeliosDB-Lite includes an automatic query plan cache that eliminates redundant parsing, planning, RLS evaluation, and optimization for repeated SQL strings. In benchmarks this delivers up to 129x speedup for cached queries. The cache is transparent — no configuration needed — and automatically invalidates on DDL changes.
Prerequisites
- HeliosDB-Lite v3.5+
- A database with tables for testing
How It Works
Cache Architecture
- Hash computation — When a query arrives, HeliosDB computes a 64-bit hash of the SQL string using the standard Rust hasher.
- Cache lookup — The hash is checked against an in-memory
HashMap<u64, LogicalPlan>protected by aRwLock. Multiple readers can check the cache concurrently. - Cache hit — If found, the stored optimized
LogicalPlanis cloned and executed directly, skipping parse, plan, and optimize phases. - Cache miss — The query goes through the full pipeline: parse, plan, RLS enforcement, optimization. The resulting plan is stored in the cache for future use.
What Gets Cached
- The fully optimized
LogicalPlanafter all optimizer rules have run - Includes predicate pushdown, join reordering, materialized view rewrites
What Triggers Invalidation
Any DDL statement clears the entire plan cache and increments the cache generation counter:
- CREATE TABLE / DROP TABLE / ALTER TABLE
- CREATE INDEX / DROP INDEX
- CREATE VIEW / DROP VIEW / CREATE MATERIALIZED VIEW
- GRANT / REVOKE (privilege changes affect query access)
- CREATE ROLE / DROP ROLE
This ensures that queries always run against current schema and privileges.
Step 1: Create Test Data
CREATE TABLE orders ( id INT PRIMARY KEY, customer_id INT, amount DECIMAL, status TEXT, created_at TIMESTAMP);
CREATE INDEX idx_orders_customer ON orders (customer_id);
INSERT INTO orders VALUES (1, 100, 250.00, 'shipped', '2026-03-01');INSERT INTO orders VALUES (2, 101, 450.00, 'pending', '2026-03-02');INSERT INTO orders VALUES (3, 100, 125.00, 'shipped', '2026-03-03');INSERT INTO orders VALUES (4, 102, 800.00, 'returned', '2026-03-04');INSERT INTO orders VALUES (5, 100, 300.00, 'pending', '2026-03-05');Step 2: First Execution (Cache Miss)
SELECT * FROM orders WHERE customer_id = 100 AND status = 'shipped';On the first execution, the full pipeline runs:
- Parse: SQL text to AST
- Plan: AST to LogicalPlan
- RLS: Row-level security filter injection
- Optimize: Predicate pushdown, index selection, etc.
- Execute: Volcano-style evaluation
- Cache store: Optimized plan saved under the SQL hash
Step 3: Repeated Execution (Cache Hit)
Run the exact same SQL again:
SELECT * FROM orders WHERE customer_id = 100 AND status = 'shipped';This time steps 1-4 are skipped entirely. The cached plan is retrieved and execution starts immediately.
Measuring the Difference
Use EXPLAIN ANALYZE to see the timing breakdown:
-- First run (populates cache)EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 100 AND status = 'shipped';
-- Second run (cache hit -- plan phase shows ~0ms)EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 100 AND status = 'shipped';In benchmarks with concurrent workloads, cached queries achieve up to 129x throughput improvement because the parse/plan/optimize overhead dominates for simple queries.
Step 4: Cache Invalidation via DDL
-- This invalidates the plan cacheALTER TABLE orders ADD COLUMN notes TEXT;
-- The next query is a cache miss (full pipeline)SELECT * FROM orders WHERE customer_id = 100 AND status = 'shipped';-- Subsequent runs are cache hits againEvery DDL operation calls invalidate_plan_cache() which:
- Clears all entries from the
HashMap - Increments the
plan_cache_generationatomic counter
Step 5: Cache Behavior with Parameters
The plan cache keys on the exact SQL string. Different parameter values produce different hashes:
-- These are TWO separate cache entries:SELECT * FROM orders WHERE customer_id = 100;SELECT * FROM orders WHERE customer_id = 101;For applications that use PREPARE/EXECUTE or parameterized queries ($1,
$2), the prepared statement mechanism provides similar benefits.
Step 6: Understanding Cache Scope
- Per-database instance — Each
EmbeddedDatabasehas its own plan cache. - Shared across connections — The cache uses
Arc<RwLock<...>>so all connections to the same database share the cached plans. - No size limit — The cache grows with the number of unique SQL strings. In practice, applications use a finite set of query patterns.
- Not persisted — The cache is in-memory only and starts empty on database restart.
Performance Characteristics
| Operation | Without cache | With cache | Speedup |
|---|---|---|---|
| Simple SELECT | ~50 us | ~0.4 us | ~129x |
| Complex JOIN | ~200 us | ~5 us | ~40x |
| Aggregation | ~100 us | ~2 us | ~50x |
| INSERT (DML) | ~30 us | ~30 us | 1x (no cache) |
DML statements (INSERT, UPDATE, DELETE) go through the standard pipeline and are not cached because they modify data.
Step 7: Monitoring
Check optimizer statistics
SELECT * FROM pg_stat_optimizer;This view shows optimizer metrics including rules applied and planning times, which drop to zero for cached queries.
Check database-level statistics
SELECT datname, xact_commit, tup_returned, tup_fetchedFROM pg_stat_database;Best Practices
-
Use consistent SQL strings — The cache matches on exact string equality. Differences in whitespace, capitalization, or comments produce separate cache entries. Normalize SQL in your application layer.
-
Use prepared statements for variable queries — Instead of embedding values in SQL strings, use
PREPAREandEXECUTEwith parameters:PREPARE get_orders AS SELECT * FROM orders WHERE customer_id = $1;EXECUTE get_orders(100);EXECUTE get_orders(101); -
Minimize DDL during peak hours — Every DDL statement flushes the entire cache. Batch schema changes during maintenance windows.
-
Let the cache warm up — After a restart, the first execution of each query will be slower. In latency-sensitive applications, consider a warm-up phase that runs common queries.
Common Pitfalls
-
String sensitivity —
SELECT * FROM ordersandselect * from ordersare different cache entries (different hash). Standardize case in your application. -
DML does not cache — INSERT, UPDATE, and DELETE always go through the full pipeline. The cache only benefits read queries.
-
GRANT/REVOKE invalidates cache — Changing privileges flushes the cache because cached plans may reference old access rules. This is correct but means frequent privilege changes have a performance cost.
-
No partial invalidation — The cache is all-or-nothing. There is no per-table invalidation. A DDL change on any table flushes plans for all tables.
-
Memory — Each cached plan is a cloned
LogicalPlan. For applications with millions of unique SQL strings, memory usage can grow. In practice, most applications have thousands of unique patterns at most.