c3 — Structured Data/Limitations

c3 Limitations


Write concurrency

Single-writer model. SQLite allows only one writer at a time per database. Concurrent write requests to the same Cell are serialized — one waits while the other holds the write lock. Read requests are unaffected and proceed concurrently.

In practice, the queue rarely matters: write operations in SQLite are fast (microseconds to low milliseconds), so the wait is negligible unless you have extreme write throughput. If your workload is write-heavy, minimize the work done per statement and use batch() to coalesce multiple writes into a single round-trip.


No stored procedures

Stored procedures do not exist in SQLite. Run the equivalent SQL statements in your handler or in a batch() call. (Triggers, by contrast, are supported — CREATE TRIGGER works normally, as does full-text search via FTS5.)


No streaming results

All query results are returned as a complete in-memory array. For very large result sets, use cursor-based pagination (LIMIT / OFFSET or keyset pagination on an indexed column) rather than fetching thousands of rows at once.


No interactive transactions

The binding API does not expose raw BEGIN / COMMIT — you cannot hold a transaction open across multiple calls or invocations. For multi-statement atomicity, use batch(): the whole batch runs as a single transaction on one connection, and any statement error rolls back every statement in the batch. Submit all related writes together within a single fetch or pulse invocation.


No cross-database queries

Each c3 binding targets a single database. There is no way to JOIN across two databases in a single query. If you need data from two databases in one response, query each binding separately and join the results in JavaScript.


No connection pooling

Each Cell invocation uses a single database connection for the duration of the request. There is no persistent connection pool between requests. Connection setup is fast, but for very latency-sensitive paths it is worth minimizing the number of round-trips by using batch().


Statements c3 will not run

Four statements are rejected before they reach SQLite, on every path — including inside a batch():

Statement Why Error
ATTACH / DETACH Every database is a file in a shared directory; attaching one reaches outside your account ATTACH is not allowed on a c3 database
VACUUM VACUUM INTO '<path>' writes a full copy of the database to an arbitrary path VACUUM is not allowed on a c3 database
PRAGMA Several pragmas (cache_size, mmap_size, temp_store) are one-statement memory-exhaustion primitives against a process shared by every account, and writable_schema edits the schema table directly PRAGMA is not allowed on a c3 database

Introspection still works — use the read-only pragma_* table-valued functions instead of the statement form:

SELECT * FROM pragma_table_info('notes');   -- works
SELECT * FROM pragma_index_list('notes');   -- works
PRAGMA table_info(notes);                   -- rejected

Everything else is ordinary SQLite: CREATE TRIGGER, FTS5, CTEs, window functions, JSON functions and so on all work normally.


Query and payload caps

These bound a single request. Exceeding one is an error, not a truncation — you always get all your rows or a clear failure, never a silent partial result.

Cap Limit Error
Rows in one result set 100,000 Result set too large: more than 100000 rows. Add a LIMIT or paginate.
Size of one result set 64 MiB Result set too large: over 67108864 bytes. Add a LIMIT or paginate.
Request body (your SQL + bound parameters) 32 MiB HTTP 413

If you hit a result-set cap, add a LIMIT and paginate (keyset pagination on an indexed column is the cheapest form). A recursive CTE with no termination condition is the usual way to hit it by accident.


Size and retention

Keep databases modest. Very large databases increase query latency and memory usage during reads. Keep individual databases under a few hundred megabytes for best performance. Very large BLOB values (images, documents) belong in g7 object storage, not in c3.

Databases persist until explicitly dropped. ribo db drop <name> permanently and irreversibly deletes a database. There is no automatic expiry, retention policy, or recycle bin.


No binary/UUID primary key type

SQLite has no native UUID column type. Store UUIDs as TEXT (36-character hyphenated string) — a 16-byte BLOB encoding is not practical through the binding, since binary parameters cannot be bound. crypto.randomUUID() returns a text UUID suitable for direct storage.


See also