milvusql
A PEP 249 DBAPI (sync + async) for Milvus — parses/generates MilvusQL via sqlglot-milvus and executes the resulting AST against pymilvus.
Why a DBAPI, not a client wrapper?
| Feature | milvusql | raw pymilvus |
|---|---|---|
| Query surface | SQL (MilvusQL) | Python method calls |
| Parameterized queries | ✅ :name binds |
⚠️ manual dict-building |
Standard Connection/Cursor (PEP 249) |
✅ | ❌ |
| Sync + async, same dispatch table | ✅ | ⚠️ separate MilvusClient/AsyncMilvusClient |
| Drop-in for SQLAlchemy / Django | ✅ milvusql-sqlalchemy, milvusql-django |
❌ |
Auto-LOAD on first use, cached per connection |
✅ | manual load_collection() |
| Consistency-level fallback (per-connection default, per-query override) | ✅ | manual per-call |
JOIN / GROUP BY / subqueries / correlated EXISTS |
✅ planned into one read per collection, combined client-side | ❌ Milvus has none of them |
Full-text search (BM25, MATCH ... AGAINST) |
✅ one TEXT column + one generated SPARSEVEC |
⚠️ schema Function + analyzer flags by hand |
| Reads past the 16384-row per-call ceiling | ✅ transparent primary-key-cursor pages | ⚠️ query_iterator (sync client only) |
Introspection (SHOW TABLES, DESCRIBE) |
✅ | Python method calls |
Writing MilvusQL instead of chaining pymilvus calls means the same SELECT ... ORDER BY embedding <=> :q LIMIT n string works whether it's typed by hand, generated by an ORM, or built by an LLM tool call — and it works the same way from cursor.execute() or await acursor.execute(), off one shared parser and dispatch table (translate.ast_to_pymilvus).
Installation
pip install milvusql
Quick start
import milvusql
conn = milvusql.connect(uri="./items.db") # Milvus Lite, or a real server's URI
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
embedding VECTOR(8),
category VARCHAR(64)
) WITH (shards=1, consistency_level='Strong')
"""
)
cur.execute(
"CREATE INDEX idx_embedding ON items (embedding) USING HNSW WITH (metric_type='COSINE')"
)
cur.executemany(
"INSERT INTO items (embedding, category) VALUES (:embedding, :category)",
[{"embedding": [0.1] * 8, "category": "book"}],
)
cur.execute("SELECT id FROM items WHERE category = :cat LIMIT 10", {"cat": "book"})
print(cur.fetchall())
cur.execute(
"SELECT id FROM items ORDER BY embedding <=> :q LIMIT 5",
{"q": [0.1] * 8},
)
print(cur.fetchall())
Full-text search is one column and one generated field away — Milvus's BM25 pipeline (analyzer, schema Function, sparse index) spelled as SQL:
cur.execute(
"""
CREATE TABLE docs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
content TEXT,
content_sparse SPARSEVEC GENERATED ALWAYS AS (BM25(content)),
embedding VECTOR(768)
)
"""
)
cur.execute(
"CREATE INDEX idx_fts ON docs (content_sparse) "
"USING SPARSE_INVERTED_INDEX WITH (metric_type='BM25')"
)
# keyword filter
cur.execute(
"SELECT id, content FROM docs WHERE MATCH(content) AGAINST (:q)",
{"q": "vector database"},
)
# BM25-ranked retrieval
cur.execute(
"SELECT id, content FROM docs "
"ORDER BY BM25_SCORE(content_sparse, :q) DESC LIMIT 10",
{"q": "how do i tune hnsw"},
)
# dense + full-text hybrid, fused with RRF
cur.execute(
"""
SELECT id, content FROM docs HYBRID SEARCH (
embedding <=> :dv WEIGHT 0.6,
BM25_SCORE(content_sparse, :q) WEIGHT 0.4
) RERANK RRF(k=60) LIMIT 10
""",
{"dv": query_embedding, "q": "how do i tune hnsw"},
)
And the surface a person (or an LLM agent) orients with:
SHOW TABLES; -- list_collections()
SHOW DATABASES; -- list_databases()
DESCRIBE docs; -- fields, types, keys, BM25 generators
CREATE DATABASE tenant_a; USE tenant_a; DROP DATABASE tenant_a;
DROP INDEX idx_fts ON docs;
The same program, asyncio-native, over milvusql.aio (built on pymilvus.AsyncMilvusClient):
from milvusql import aio
conn = aio.connect(uri="./items.db")
cur = conn.cursor()
await cur.execute("SELECT id FROM items WHERE category = :cat", {"cat": "book"})
async for row in cur:
print(row)
await conn.close()
JOIN, GROUP BY and subqueries
Milvus reads one collection per RPC, joins nothing and reduces nothing.
milvusql closes that gap without pretending it isn't there: a statement
that needs more than one collection, a grouped aggregate or a subquery is
planned into one Milvus read per collection, and the relational part is
evaluated client-side with Polars.
cur.execute(
"""
SELECT c.title, COUNT(*) AS n, AVG(i.price) AS avg_price
FROM items AS i
JOIN categories AS c ON i.cat_id = c.id
WHERE i.price > :floor
GROUP BY c.title
HAVING COUNT(*) > 1
ORDER BY n DESC
LIMIT 10
""",
{"floor": 20.0},
)
What reaches Milvus, and what does not:
| Pushed to Milvus | Evaluated client-side |
|---|---|
Every WHERE conjunct naming a single collection (i.price > 20) |
Predicates spanning two collections (i.price > c.budget) |
| The columns the statement actually references (projection pushdown) | Joins (INNER/LEFT/RIGHT/FULL/CROSS, ON or USING) |
ORDER BY <vector> <=> :q LIMIT k as a real ANN search |
GROUP BY, HAVING, aggregates, window functions |
Equi-join keys learned from the previous read, as key in [...] |
WITH (CTEs), UNION/INTERSECT/EXCEPT, subqueries |
Scalar ORDER BY, DISTINCT, LIMIT/OFFSET |
Window functions are the one worth calling out against a vector database:
ROW_NUMBER() OVER (PARTITION BY category ORDER BY distance) over a search's
hits is top-k per group, which Milvus cannot express and an ANN index
cannot answer directly.
SELECT * works across a join too, but it means what it says: each side is
asked for output_fields=["*"], so every field of every collection comes
back — vectors included. Naming the columns is the difference between
moving a few scalars and moving every embedding.
The key pushdown is what keeps this usable: an ANN search returning 50 hits joined against a million-row collection reads 50 rows from it, not a million.
Three behaviours worth knowing before you rely on them:
- Reads page past Milvus's 16384-row per-call ceiling. A scan that
comes back at the ceiling continues with primary-key-cursor pages
(the same
iteratorprotocol pymilvus's ownQueryIteratorspeaks), so joins, groupings, aggregates, client-sideORDER BY,UPDATEand a bareSELECTwith noLIMITall cover every matching row — on both the sync and async cursors. The one server that cannot serve ordered pages is Milvus Lite: there a result past the ceiling still raisesNotSupportedError(never a silent truncation), same as before. No snapshot spans the pages; rows written mid-read may or may not appear, exactly as with pymilvus's own iterator. ORDER BY <vector> ... LIMIT kin a joined query means "Milvus's top-k from that collection, then join" — not "top-k of the joined result". The two differ only when the join or a cross-collection predicate drops rows; ranking after the join would mean reading the whole vector collection, which is the one thing an ANN index exists to avoid.- Columns must be unambiguous. With more than one collection in scope, a
bare
idraisesProgrammingErrorasking you to qualify it: output fields are requested from Milvus before any row comes back, and no collection schema is available at that point to resolve the name against.
Through the ORMs
Both packages inherit this at the DBAPI level — no ORM-side change was needed — so the ordinary constructs compile and run:
| SQLAlchemy | Django |
|---|---|
select(...).join(...) / .outerjoin(...) |
filter(related__field=...), select_related(...) |
.group_by(...) / .having(...) |
.values(...).annotate(Count(...)), then .filter(n__gt=...) |
col.in_(select(...)) |
filter(fk__in=Model.objects.values("id")) |
Query.count() (still a server-side count(*), no rows fetched) |
.count() |
Django's compiler groups and orders by ordinal position (GROUP BY 1,
ORDER BY 2 DESC) rather than by name, and both ORMs quote every
identifier; the planner resolves both spellings.
Correlated [NOT] EXISTS — what SQLAlchemy's .any()/.has() and
Django's Exists(... OuterRef(...)) compile to — is decorrelated into a
semi/anti join (the rewrite SQL engines perform for the same shape), so
those ORM constructs run. The correlation must be an equality; SQL's own
NULL semantics are kept (a null key never matches EXISTS, always
survives NOT EXISTS).
Not supported, and rejected explicitly rather than mistranslated:
- Correlated subqueries beyond
EXISTSequality — Django'sSubquery(...)annotations (a correlated value per outer row) and non-equiEXISTScorrelations. The error names the construct. WITH RECURSIVE(re-reads until a fixpoint),INTERSECT ALL/EXCEPT ALL(duplicate-count semantics a semi/anti join cannot express), window frame clauses (ROWS/RANGE BETWEEN),LAG/LEAD/NTILE,JOIN ... USINGpast two sources, andSELECT *inside a subquery that joins — the last two because two collections can own the same column name, and nothing at translate time says which.
Anything that doesn't need this path — a filter SELECT, a vector search, a
hybrid search, a bare COUNT(*) — is still exactly one RPC and never builds a
DataFrame.
API
milvusql.connect()
milvusql.connect(
uri="http://localhost:19530", # or a Milvus Lite file path
token="", # "user:password", or a full token string
db_name="",
consistency_level=None, # per-connection default; a query's own CONSISTENCY LEVEL wins
**kwargs, # passed straight through to pymilvus.MilvusClient
) -> Connection
Connection |
Description |
|---|---|
.cursor() |
Returns a new Cursor bound to this connection |
.commit() |
No-op — every statement is already applied when it returns |
.rollback() |
Raises NotSupportedError — Milvus has no multi-statement rollback; catch and compensate instead |
.close() |
Closes the underlying MilvusClient |
| Context manager | with milvusql.connect(...) as conn: ... |
Cursor |
Description |
|---|---|
.execute(operation, parameters=None) |
Runs one statement; parameters binds :name placeholders |
.executemany(operation, seq_of_parameters) |
Batched INSERT in one round trip where the statement allows it; falls back to one call per parameter set otherwise |
.fetchone() / .fetchmany(size) / .fetchall() |
Read back result rows |
.description, .rowcount, .lastrowid, .arraysize |
Standard PEP 249 attributes |
| Iteration | for row in cursor: ... |
milvusql.aio.connect()/AsyncConnection/AsyncCursor mirror the same shape, async/await throughout — deliberately not PEP 249 itself (execute() as a coroutine can't be), but built on the same parser, dispatch table, and error hierarchy as the sync path.
Column types
| MilvusQL | Milvus field type | Notes |
|---|---|---|
BIGINT / INT / SMALLINT / TINYINT |
INT64/32/16/8 |
PRIMARY KEY [AUTO_INCREMENT] on BIGINT/VARCHAR |
FLOAT / DOUBLE / BOOLEAN / JSON |
ditto | JSON paths filter server-side: WHERE meta['brand'] = :b |
VARCHAR(n) |
VARCHAR |
|
TEXT |
analyzer-enabled VARCHAR(65535) |
full-text input: MATCH ... AGAINST + BM25 |
ARRAY<T>(capacity) |
ARRAY |
ARRAY_CONTAINS/_ALL/_ANY, ARRAY_LENGTH filter server-side |
VECTOR(dim) |
FLOAT_VECTOR |
|
SPARSEVEC |
SPARSE_FLOAT_VECTOR |
GENERATED ALWAYS AS (BM25(text_col)) for full-text |
BINARYVEC(dim) / FLOAT16VEC(dim) / BFLOAT16VEC(dim) / INT8VEC(dim) |
BINARY/FLOAT16/BFLOAT16/INT8_VECTOR |
bind values as bytes / numpy arrays, passed through untouched |
Errors
Standard PEP 249 hierarchy, importable from milvusql:
Warning
Error
├── InterfaceError
└── DatabaseError
├── DataError
├── OperationalError
├── IntegrityError
├── InternalError
├── ProgrammingError
└── NotSupportedError
Every pymilvus exception and gRPC error raised while executing a statement is translated into one of these before it reaches your code.
Packages
This is the core of a uv workspace. Two packages build on milvusql's DBAPI:
| Package | Description |
|---|---|
milvusql-sqlalchemy |
SQLAlchemy 2.0 dialect — VECTOR/SPARSEVEC column types, hybrid_search(), Alembic support |
milvusql-django |
Django database backend — VectorField, ORM CRUD/filtering through the normal compiler |
Each is installed and versioned separately; both depend on this package as their DBAPI layer.
Examples
| Example | Shows |
|---|---|
examples/basic_walkthrough |
A guided, top-to-bottom tour of the DBAPI: connect, CREATE TABLE/CREATE INDEX, insert, filter SELECT, vector search, UPDATE/DELETE — sync and async |
examples/temporal_worker |
A Temporal workflow/activity that inserts rows into Milvus as a durable, retry-safe ingestion pipeline |
See also milvusql-sqlalchemy's own examples (a FastAPI image-search service, a pydantic-ai agent).
Development
Requires Python 3.12+, uv, task.
task install # uv sync --all-groups --all-packages
task lint # ruff + ty + bandit for core + all packages
task tests # all tests (core + sqlalchemy + django) -- integration tests need Docker (testcontainers)
Individual package tasks:
task core:lint task core:test
task sqlalchemy:lint task sqlalchemy:test
task django:lint task django:test
See benchmarks/ for what the planner's key/predicate
pushdown buys, measured through the public DBAPI, and
CONTRIBUTING.md for ground rules.
License
MIT
Release files for milvusql 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| milvusql-1.0.0.tar.gz | 70.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| milvusql-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 148.0 kB
Release files / milvusql-1.0.0.tar.gz
| Download URL | milvusql-1.0.0.tar.gz |
|---|---|
| Size | 70.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
445aef7fa75e4527f8fef1c24ec6af35e0507220fcd4dc3c101ad16f68b52dd0
|
|
BLAKE2b-256 checksum How to use checksums |
71a7ddbb03908b2c3c4c2bbb441970f912416246b0bb36b677a72f0f7064f979
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","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}
|
Release files / milvusql-1.0.0-py3-none-any.whl
| Download URL | milvusql-1.0.0-py3-none-any.whl |
|---|---|
| Size | 77.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f56ff6257d606771e68e68699806f76bc9ea042346635c9991a8650278b3e8cd
|
|
BLAKE2b-256 checksum How to use checksums |
f4d053318ec754c9b1d285cfbb08abe323f52d6e7788a1b6a4ea1f48a419f48c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","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}
|