iceql
A local RDBMS with plaintext storage (CSV + YAML). Like SQLite, a database is self-contained and queryable with SQL — but the storage stays human-readable.
SQLite database files are binary, so you cannot hand one to an LLM and have it read the contents directly. iceql stores tables as CSV and schemas as YAML, so both LLMs and humans can read the storage as-is. Writes always produce a canonical form (LF newlines, minimal quoting, one record per line), which keeps git diffs clean and meaningful.
Installation
$ uv tool install iceql # as a CLI
$ uv add iceql # as a library
Quick start
A database is just a directory.
$ iceql mydb -c "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER)"
$ iceql mydb -c "INSERT INTO users VALUES (1, 'alice', 30), (2, 'bob', NULL)"
$ iceql mydb -c "SELECT * FROM users WHERE age IS NULL"
id,name,age
2,bob,\N
The resulting files are readable as-is.
$ cat mydb/users.csv
id,name,age
1,alice,30
2,bob,\N
$ cat mydb/users.schema.yaml
version: 1
table: users
columns:
- name: id
type: integer
nullable: false
primary_key: true
- name: name
type: text
nullable: false
- name: age
type: integer
nullable: true
null_marker: \N
Running without arguments opens a psql-style REPL.
$ iceql mydb
iceql (0.1.1)
Type "\?" for help.
mydb=# SELECT COUNT(*) FROM users;
_col_0
------
2
mydb=# \d
List of tables
users
mydb=# \x
Expanded display is on.
mydb=# SELECT * FROM users;
-[ RECORD 1 ]-
id | 1
name | alice
age | 30
-[ RECORD 2 ]-
id | 2
name | bob
age |
mydb=# \q
Meta commands: \d [table] (list / describe tables), \x (toggle expanded output), \pset format table|csv|json, \? (help), and \q (quit).
-f table|csv|json selects the output format (defaults to table on a TTY, csv when piped).
With -f csv, NULL is written as \N, the same convention as the stored CSV files. --null-marker changes that for tools that expect something else: iceql mydb -c "SELECT * FROM users" -f csv --null-marker '' > users.csv. An empty marker makes a NULL and an empty string look the same in the output; the distinction is gone and cannot be recovered by reading the file back.
iceql check mydb validates schema/CSV consistency (types, NOT NULL, duplicate primary keys, UNIQUE, CHECK, canonical form) and exits non-zero on errors.
This makes hand-edited CSV files verifiable in CI or a pre-commit hook.
Importing CSV
iceql import turns an existing CSV file into a table. Column names come from the header row, and types are inferred by scanning every row. The inferred schema is printed before anything is written.
$ iceql import mydb users.csv
users.csv: 2 rows, 3 columns -> table 'users'
id integer not null
name text not null
age integer null
imported 2 rows into users
$ iceql import mydb users.csv --table people # table name (default: the file name)
$ iceql import mydb users.csv --types zip=text # override the inferred type (repeatable)
$ iceql import mydb users.csv --dry-run # show the inferred schema and stop
$ cat users.csv | iceql import mydb - --table users
A type is only inferred when reading a value and writing it back leaves the text unchanged, so a zero-padded 007 stays text instead of silently becoming 7. A column holding both integers and decimals becomes real. Anything else falls back to text, which loses nothing; --types overrides the result.
NULL follows the same convention as everywhere else in iceql: an unquoted \N is NULL and an empty field is the empty string. For CSV files where an empty field means NULL, pass --null-marker '' (or --null-marker NA, and so on). --encoding (default utf-8-sig) reads files that are not UTF-8.
A column is nullable only when it actually contains a NULL, primary keys are not inferred, and importing into an existing table is an error. When the inference does not match your intent, edit the generated <table>.schema.yaml.
Python API
A DB-API 2.0 style API, familiar to anyone who has used the sqlite3 module.
import iceql
conn = iceql.connect("mydb")
conn.execute("INSERT INTO users VALUES (?, ?, ?)", (3, "carol", 25))
for row in conn.execute("SELECT name FROM users WHERE age > :min", {"min": 20}):
print(row)
conn.close()
Every write rewrites the whole CSV, so running one INSERT per row costs time proportional to the square of the row count.
executemany builds all the rows in memory and writes the file once.
conn.executemany("INSERT INTO users VALUES (?, ?, ?)", rows)
For anything executemany does not cover (mixed statements, UPDATE against many keys), wrap the statements in a transaction: they are staged in memory and written once at COMMIT.
Supported SQL
- SELECT: WHERE, JOIN (INNER / LEFT), GROUP BY, aggregate functions, HAVING, ORDER BY (with NULLS FIRST / LAST), LIMIT / OFFSET, DISTINCT,
IN/NOT IN/EXISTS/NOT EXISTSsubqueries, CTE (WITH), UNION / UNION ALL - DML: INSERT (VALUES / SELECT), UPDATE, DELETE
- RETURNING: INSERT / UPDATE / DELETE hand back the rows they wrote through the same interface as SELECT (
descriptionandfetchall()). INSERT and UPDATE return the row as written, DELETE the row as it was before removal. Aggregate functions and subqueries are not allowed inside RETURNING - Conflict resolution on INSERT:
INSERT OR IGNORE,INSERT OR REPLACE,ON CONFLICT ... DO NOTHING, andON CONFLICT ... DO UPDATE(includingexcluded.<column>and aWHEREclause). The conflict target must name the primary key or a UNIQUE constraint.OR IGNOREskips a row on any constraint violation,DO NOTHINGonly on a key collision — the same split SQLite makes - DDL: CREATE TABLE, DROP TABLE, ALTER TABLE (ADD / DROP / RENAME COLUMN, RENAME TO)
- Constraints: PRIMARY KEY, NOT NULL, DEFAULT, UNIQUE, CHECK. UNIQUE and CHECK are recorded in the schema YAML and enforced on INSERT / UPDATE;
iceql checkapplies the same two checks to hand-edited CSV. NULL follows SQLite: keys containing a NULL never collide, and a CHECK that evaluates to NULL passes - Transactions: BEGIN / COMMIT / ROLLBACK (changes are staged in memory and flushed on COMMIT). DDL is allowed inside a transaction, so a schema change and the data migration that goes with it commit as one unit. Writers are serialized: BEGIN takes a database-wide write lock held until COMMIT / ROLLBACK, and other writers wait for it (up to
connect(timeout=...)seconds, thenOperationalError). SELECTs are never blocked by an open transaction — they only wait during the brief COMMIT flush. COMMITs are crash-safe: staged changes are first written to a redo journal, and an interrupted COMMIT is completed automatically the next time the database is opened - Placeholders:
?(qmark) and:name(named)
SQL parsing and SELECT execution are powered by sqlglot. NULL ordering follows the SQLite default (NULL sorts smallest: first in ASC, last in DESC).
Primary key auto-assignment
A table whose primary key is a single integer column assigns the value itself when an INSERT leaves it out or passes NULL.
The assigned value is the largest existing value plus one, or 1 when the table is empty.
Cursor.lastrowid holds the value given to the last inserted row (None for tables without such a primary key).
$ iceql mydb -c "INSERT INTO users (name, age) VALUES ('dave', 41)"
$ iceql mydb -c "SELECT * FROM users WHERE name = 'dave'"
id,name,age
4,dave,41
AUTOINCREMENT is accepted on such a column and means exactly the same thing (it is an error anywhere else, as in SQLite).
It does not carry SQLite's guarantee that a value is never reused: deleting the row with the largest value makes that value available again.
Never reusing a value would require a counter stored outside the CSV, and hand-editing the CSV would then leave the counter behind.
Types
| Type | CSV representation |
|---|---|
| integer | decimal integer |
| real | floating point (shortest round-trip form) |
| boolean | true / false |
| text | string (quoted only when it contains , " or newlines) |
| date | YYYY-MM-DD |
| datetime | ISO-8601 |
NULL is represented as an unquoted \N (the same convention as PostgreSQL COPY).
An empty string is an empty field, so NULL and the empty string are distinguishable.
A literal string \N is escaped as \\N.
MCP server
A built-in MCP server lets LLM agents read and write the database directly.
$ uv tool install 'iceql[mcp]'
$ iceql mcp mydb --read-only # drop --read-only to enable write tools
Four tools are exposed: query (SELECT only), execute (DML / DDL), list_tables, and describe_table.
Limitations
- Window functions, DISTINCT inside aggregates (e.g.
COUNT(DISTINCT x)), scalar subqueries in the SELECT list, andUPDATE ... FROMare not supported (they fail with a clear error) - A subquery with LIMIT / OFFSET is evaluated on its own before the outer query and replaced by its result, so LIMIT / OFFSET works in a FROM-clause subquery, a CTE, a scalar subquery, and an
IN/EXISTSsubquery. The evaluation is not short-circuited: the subquery is read in full even underEXISTS - LIMIT / OFFSET in a correlated subquery (one that references a column of the outer query) is not supported, because a single evaluation cannot determine its result. It fails with a clear error
- A correlated
EXISTS/NOT EXISTSis evaluated by rewriting it as a join. That rewrite applies when the correlation condition is a conjunction of comparisons placed directly in the subquery's WHERE, at least one of them an equality. Shapes without an equality (e.g.EXISTS (SELECT 1 FROM u WHERE u.id > s.id)), with anORin the condition, or with a non-comparison predicate such as aBETWEENover columns from both sides are not supported (they fail with a clear error); rewrite them as a comparison against an aggregate (s.id < (SELECT MAX(u.id) FROM u)) or as a join withDISTINCT - Before that rewrite, the subquery of a correlated
EXISTSis normalized in ways that cannot change whether it has rows: GROUP BY and ORDER BY are dropped, an aggregate without GROUP BY folds to true (it always returns one row), and a predicate determined by outer columns alone is moved out of theEXISTS. Without this the rewrite silently changes the result (GROUP BY makes the join key non-unique and multiplies outer rows, and a predicate on outer columns under aNOTloses its negation). A subquery with both GROUP BY and HAVING (HAVING changes whether rows exist, so GROUP BY cannot be dropped) and aNOTover a predicate that mixes inner and outer columns cannot be normalized and are not supported (they fail with a clear error) - A correlated
EXISTS/NOT EXISTShas to sit in a WHERE clause. The rewrite appends the subquery as a LEFT JOIN at the end of the FROM clause and leaves the test where it was, so the sameEXISTSin a join's ON clause or in HAVING would read a column that is not there yet; both are not supported (they fail with a clear error). In an inner join's ON clause, move it to WHERE; in an outer join's ON clause, where moving it would change the result, move the subquery into the FROM clause and join against it; in HAVING, move it to WHERE if it only references grouped columns. An uncorrelatedEXISTSfolds to a boolean, so its position does not matter - A
NOT INsubquery is evaluated on its own and folded into an equivalent expression that keeps SQL's three-valued logic, so a NULL on either side behaves as it does in SQLite. The folded expression holds in any position, including one where NULL and false differ (inside CASE, under a second NOT, in the SELECT list). This needs the value set to be known up front, so a correlatedNOT INand a multi-columnNOT INare not supported; rewrite them asNOT EXISTS. Both fail with a clear error - Tables are fully loaded into memory at query time; the intended scope is databases small enough for an LLM to read directly (tens of thousands of rows)
- UPDATE and DELETE take a fast path when the WHERE clause is absent or is only
column = constantjoined by AND, and (for UPDATE) every SET right-hand side is a constant or a bare column of the same table. Any other shape is evaluated by running the statement as a SELECT over the whole table, so its cost is proportional to the table size rather than to the number of rows changed - UPDATE re-checks PRIMARY KEY and UNIQUE only when the SET clause assigns to a column of one of those keys. Key values in the other rows do not move, so an update cannot introduce a duplicate; a duplicate that was already in a hand-edited CSV is not reported. CHECK is likewise only evaluated on the rows the statement changed
- A connection keeps the tables it has read and reloads them when the inode, mtime, or size of the CSV or schema file changes. Writes from iceql always replace the file, so they are always picked up; on a filesystem with one-second mtime resolution, an external overwrite of the same size within the same second can be missed
- FOREIGN KEY is not supported; a
REFERENCESclause in CREATE TABLE fails with a clear error rather than being silently dropped INSERT OR ROLLBACKandREPLACE INTOare not supported (both fail with a clear error); useINSERT OR REPLACEfor the latter- Windows is not supported (inter-process locking uses fcntl)
Development
$ uv sync --all-groups
$ uv run pytest
$ uv run ruff check src tests
$ uv run mypy
The test suite includes differential tests that run the same queries against both iceql and sqlite3 and compare the results.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file iceql-0.3.0.tar.gz.
File metadata
- Download URL: iceql-0.3.0.tar.gz
- Upload date:
- Size: 61.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ed503d043a35bfa475d438384709f62a9150cddc81bf15a0e35d83d0c0759f92
|
|
| MD5 |
7cd58523058ef686d0be7314037dfb06
|
|
| BLAKE2b-256 |
04d4e56fe6105d732d7d765999bbf06bb917e045236a74da932d252b11b6ab4b
|
File details
Details for the file iceql-0.3.0-py3-none-any.whl.
File metadata
- Download URL: iceql-0.3.0-py3-none-any.whl
- Upload date:
- Size: 74.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
499681b98f83c75153f21c258c026a186242b629e3e59e880b5ebdfccdcdffb4
|
|
| MD5 |
a3534e2467a00b80aad695910ae9d513
|
|
| BLAKE2b-256 |
2fa1054c2ace7b1e94601434d974be5ce220e2c14978ad86636375ac99e45728
|