PostgREST-inspired SQLite HTTP REST API using Python stdlib
Project description
restqlite
=========
PostgREST-inspired HTTP REST API for SQLite, Python stdlib only.
No dependencies. Python 3.9+, compatible with 3.14 free-threaded.
INSTALL
-------
pip install restqlite
CLI
---
python -m restqlite mydb.sqlite3
python -m restqlite mydb.sqlite3 --port 5000
python -m restqlite mydb.sqlite3 --host 127.0.0.1 --read-only
python -m restqlite mydb.sqlite3 --log-level WARNING
USAGE
-----
import restqlite
# blocking
restqlite.serve('mydb.sqlite3', host='', port=8000)
# custom server (e.g. alongside other handlers)
import http.server
handler = restqlite.make_handler('mydb.sqlite3')
with http.server.ThreadingHTTPServer(('', 8000), handler) as srv:
srv.serve_forever()
serve(db_path, host='', port=8000, read_only=False,
before_request=None, routes=None,
allowed_tables=None, max_rows=None, log_level='INFO')
make_handler(db_path, read_only=False,
before_request=None, routes=None,
allowed_tables=None, max_rows=None, log_level='INFO')
db_path Path to SQLite file, or ':memory:'.
read_only If True, only GET and HEAD are accepted.
before_request Callable(handler) run before every request; return False
to signal the response has already been sent.
routes Dict mapping path or 'METHOD /path' to a callable.
Matched before restqlite routing. Example:
{'GET /health': lambda h: _send(h, 200, {'ok': True})}
allowed_tables Collection of table/view names to expose, or a
Callable(handler) returning one. None exposes all (default).
max_rows Maximum rows returned per GET request. Clamps both explicit
?limit= values and requests with no limit. None = no cap.
log_level Logging level string ('DEBUG', 'INFO', 'WARNING', …).
Sets the 'restqlite' logger level. None = don't change.
Default 'INFO'.
API REFERENCE
-------------
All tables and views are auto-discovered from the database schema.
Requests and responses use JSON by default. Column types are inferred
at query time (SQLite dynamic typing).
GET /
List tables and views.
Response:
{"tables": ["orders", "users"], "views": ["active_users"]}
When allowed_tables is set, only listed tables/views are returned.
GET /metrics
Prometheus exposition format metrics:
restqlite_requests_total{method="GET"} 42
restqlite_db_bytes 16384
Falls through to a table named "metrics" if one exists.
Use with Prometheus + grafana_dashboard.json (included in repo).
GET /<table>
Fetch rows from a table or view.
Query parameters:
select=col1,col2 return only named columns
col=op.value filter rows (see OPERATORS)
order=col.asc sort; append .desc for descending
order=col1.asc,col2.desc multi-column sort
order=col.asc.nullsfirst nullsfirst/nullslast suffix accepted (ignored)
limit=N page size
offset=N skip N rows
Prefer: count=none skip COUNT(*); omits Content-Range header
Response header:
Content-Range: 0-9/42 (first-last/total; omitted with count=none)
Content-Range: */0 (empty result set)
Example:
GET /orders?status=eq.open&order=created.desc&limit=20
HEAD /<table>
Same as GET but returns headers only (no body).
Content-Range header is still included.
POST /<table>
Insert one row (JSON object) or many rows (JSON array).
Headers:
Prefer: return=representation return inserted row(s) in response
Response (without Prefer header):
{"count": 1}
Response (with Prefer: return=representation):
{inserted row object} for a single row
[{...}, {...}] for multiple rows
Example:
POST /orders
{"product": "widget", "qty": 3, "status": "open"}
PUT /<table>
Upsert one row (JSON object) or many rows (JSON array).
Uses INSERT OR REPLACE semantics: existing rows matched by primary key
are replaced; new rows are inserted.
Headers:
Prefer: return=representation return upserted row(s) in response
Response (without Prefer header):
{"count": 1}
Response (with Prefer: return=representation):
{row object} for a single row
[{...}, {...}] for multiple rows
Example:
PUT /orders
{"id": 42, "product": "widget", "qty": 5, "status": "open"}
PATCH /<table>[?filters]
Update columns on matching rows. Body is a JSON object of columns
to set. Filters follow the same syntax as GET.
Headers:
Prefer: return=representation return updated rows in response
Response (without Prefer header):
{"count": 2}
Response (with Prefer: return=representation):
[{updated row}, ...]
Example:
PATCH /orders?status=eq.open
{"status": "closed"}
DELETE /<table>[?filters]
Delete matching rows. No filters deletes all rows.
Headers:
Prefer: return=representation return deleted rows in response
Response (without Prefer header):
{"count": 5}
Response (with Prefer: return=representation):
[{deleted row}, ...]
Example:
DELETE /orders?status=eq.closed
OPERATORS
---------
Filters take the form ?column=op.value.
Prefix with not. to negate: ?col=not.eq.foo
eq equal ?name=eq.alice
neq not equal ?name=neq.bob
lt less than ?price=lt.100
lte less than or equal ?price=lte.100
gt greater than ?price=gt.0
gte greater than/equal ?price=gte.0
like pattern match ?name=like.*ali* (* is wildcard; case-sensitive)
ilike case-insensitive ?name=ilike.*ALI* (ASCII only)
is null/bool check ?deleted=is.null
?active=is.true
?active=is.false
?col=is.unknown (alias for null)
in set membership ?id=in.(1,2,3) (parentheses required)
fts FTS5 full-text ?body=fts.hello+world (requires FTS5 table)
FTS5 binary NOT: ?body=fts.hello+NOT+world
ALLOWED TABLES
--------------
allowed_tables controls which tables and views are exposed. Tables not
in the allowed set return 404 (not 403, to avoid leaking schema info).
Static list:
restqlite.serve('mydb.sqlite3', allowed_tables=['orders', 'products'])
Callable (dynamic, e.g. DB-stored permissions):
def tables(handler):
# return a collection or None to allow all
role = handler.headers.get('X-Role', 'guest')
if role == 'admin':
return None # unrestricted
return ['products', 'categories']
restqlite.serve('mydb.sqlite3', allowed_tables=tables)
None (default) exposes all tables and views.
EXTENSIBILITY
-------------
before_request -- run before every request:
def auth_check(handler):
if handler.headers.get('Authorization') != 'Bearer secret':
handler.send_response(401)
handler.send_header('Content-Length', '0')
handler.end_headers()
return False # response already sent; restqlite must not respond
restqlite.serve('mydb.sqlite3', before_request=auth_check)
routes -- custom endpoints (bypass restqlite routing entirely):
import json
def health(handler):
body = json.dumps({'status': 'ok'}).encode()
handler.send_response(200)
handler.send_header('Content-Type', 'application/json')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(body)
restqlite.serve('mydb.sqlite3', routes={
'GET /health': health,
'/ping': health, # matches any method
})
MULTI-TENANCY
-------------
Isolate tenants with separate SQLite database files (one file per tenant).
Use routes or before_request to select the appropriate db_path and
dispatch to a per-tenant handler class.
ERROR RESPONSES
---------------
All errors return JSON:
{"error": "description"}
Status codes:
400 bad request (invalid filter, unknown column, SQLite error)
404 table or view not found (also returned for allowed_tables misses)
405 method not allowed (write on read-only server, or unsupported method)
THREADING
---------
Uses ThreadingHTTPServer (one thread per connection). HTTP/1.1 keep-alive
allows a single thread to serve multiple sequential requests on one
connection, and each thread holds its own SQLite connection via
threading.local. WAL journal mode is enabled for concurrent reads.
The sqlite3 module has threadsafety level 1 (threads may share the
module but not connections). Per-thread connections satisfy this.
Safe under Python 3.14 free-threaded (no-GIL) builds because no
mutable state is shared between threads.
SCHEMA NOTES
------------
No column types in CREATE TABLE statements (SQLite dynamic typing):
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
product,
qty,
status,
created
);
PRAGMA foreign_keys=ON is set on every connection.
MAX ROWS
--------
Prevent unbounded queries by setting a server-level row cap:
restqlite.serve('mydb.sqlite3', max_rows=1000)
The cap clamps both explicit ?limit= and queries with no limit parameter.
Clients cannot exceed it regardless of what they request.
LOGGING
-------
restqlite emits structured JSON logs to stdout via Python's logging module.
The logger is named 'restqlite'. Each log record is a JSON object:
{"timestamp": "...", "level": "INFO", "message": "...", "logger": "restqlite"}
Access logs (one per request) include additional fields:
{"timestamp": "...", "level": "INFO", "message": "GET /orders 200",
"logger": "restqlite",
"client_ip": "127.0.0.1", "method": "GET", "path": "/orders",
"status": 200, "duration_ms": 1.23}
A startup log is emitted when serve() binds the socket:
{"message": "restqlite listening on 0.0.0.0:8000", ...,
"db": "mydb.sqlite3", "host": "0.0.0.0", "port": 8000}
To suppress access logs, set log_level='WARNING'. To integrate with an
existing logging setup, configure logging.getLogger('restqlite') directly
and pass log_level=None to avoid overriding the level.
LIMITATIONS
-----------
- No bulk PATCH with per-row values
- Single SQLite file per server instance (use separate instances for tenants)
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
restqlite-0.1.0.tar.gz
(33.2 kB
view details)
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
restqlite-0.1.0-py3-none-any.whl
(18.6 kB
view details)
File details
Details for the file restqlite-0.1.0.tar.gz.
File metadata
- Download URL: restqlite-0.1.0.tar.gz
- Upload date:
- Size: 33.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3648b8b77dda91f134c1906b75e683f3c5b0457d236a55b5ca09c857433c562c
|
|
| MD5 |
3f7dcd81250f1bd9b4edf9e61cfe365d
|
|
| BLAKE2b-256 |
32dd4d4a1ff18b64b551eebd029f3c7c3ae26100951e1c5795b789384f5d199a
|
File details
Details for the file restqlite-0.1.0-py3-none-any.whl.
File metadata
- Download URL: restqlite-0.1.0-py3-none-any.whl
- Upload date:
- Size: 18.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2b78e8acaabcd7845e8d3517824b248a7d03e034e673eb4bc981d6e3a16bf7f8
|
|
| MD5 |
e5df383a52f0fed05ff1609a8e103a52
|
|
| BLAKE2b-256 |
2abf1f3703e3b80fab9053425e655d2a6637c6dbae1a291485e7581f8531e24a
|