Limitations¶
runic is a focused, deliberately scoped tool. This page documents what it does not do and the reasoning behind each constraint.
Autogenerate cannot detect renames¶
What this means: If you rename a property (email_address →
email) or a node label (User → Person), autogenerate will
generate a drop for the old name and a create for the new one.
Applying this migration as-is would delete the index on the old property and
create a new index on a non-existent property — the actual data is unchanged.
Why: Schema introspection APIs return the current state of indexes and constraints. There is no rename-event or change-log API in any supported backend. Detecting a rename requires comparing two names and inferring intent, which is inherently ambiguous.
What to do: Write rename operations manually using op.rename_property
and op.relabel_nodes. Do not rely on autogenerate for rename migrations.
Autogenerate covers schema objects only¶
What this means: Autogenerate diffs indexes and constraints. It does not compare node labels present in the graph, property names used on existing nodes, relationship types, or any actual data.
Why: Graph databases do not expose a declarative schema for node data. A
Person node with an email property can coexist with a Person node
without one; there is no “schema” to diff against.
What to do: Data migrations (seeding reference data, normalising values,
backfilling properties) must be written manually using op.run_cypher,
op.seed, op.rename_property, and op.relabel_nodes.
No property-type diffing¶
What this means: Autogenerate detects the presence or absence of an index
but cannot detect that an indexed property changed type (e.g. string →
integer).
Why: The schema introspection queries used by runic return only the index
type (RANGE, FULLTEXT, VECTOR) and the property name. No
property-type metadata is available.
What to do: If you need to change the type of an indexed property, write a migration that drops the old index, migrates the data, and recreates the index.
No index-options drift detection¶
What this means: Autogenerate detects the presence or absence of a vector
index but does not detect changes to its HNSW parameters (dimension,
similarity, m, ef_construction, ef_runtime).
Why: A parameter change always requires a drop + create regardless of
backend — there is no ALTER INDEX path. Comparing floating-point options
reliably against live schema data is fragile and would not unlock a simpler
migration path anyway.
What to do: Write vector index parameter changes as explicit migrations
using op.drop_vector_index + op.create_vector_index.
ORM schema validation vs. autogenerate introspection¶
These are two separate concerns that use different code paths:
ORM schema validation (IndexManager / SchemaManager) uses
get_existing_specs() to read the live schema at startup and diff/sync it
against your model declarations. This is implemented for:
FalkorDB — via
CALL db.indexes()/CALL db.constraints()Neo4j — via
SHOW INDEXES/SHOW CONSTRAINTSMemgraph — via
SHOW INDEX INFO/SHOW CONSTRAINT INFO(RANGE indexes and UNIQUE / MANDATORY constraints only — FULLTEXT and VECTOR indexes are not exposed by these commands)
ArcadeDB and Apache AGE return an empty set from get_existing_specs();
every declared spec is treated as missing.
Migrate autogenerate (runic revision --autogenerate and
runic check) uses read_live_schema() to generate a diff against your
SchemaManifest. This is implemented only for FalkorDB. All other
adapters return an empty live schema from read_live_schema(). If you run
--autogenerate against a non-FalkorDB backend, runic sees the entire
manifest as “new” and generates a create-all script — it does not produce a
meaningful diff.
What to do: For Neo4j and Memgraph, use SchemaManager.validate_schema
and sync_schema at startup for live introspection. Write migration scripts
manually rather than relying on autogenerate. Autogenerate’s revision
creation still works as a scaffolding tool — review and trim the generated
creates on first run if your schema already exists.
Version state lives inside the graph¶
What this means: The current revision is stored as a node inside the graph being migrated. If the graph is deleted, the version pointer is lost.
Consequences:
Deleting and recreating a graph loses version tracking. Use
runic stampto re-attach the version pointer after recreating.On FalkorDB, copying a graph via
GRAPH.COPYalso copies the version node. A copied graph already knows its revision.There is no separate version table, file, or external metadata store.
No async client support¶
What this means: All migration adapters use synchronous (blocking) I/O. runic does not integrate with asyncio or any async graph client.
Why: The core upgrade/downgrade loop calls adapter methods
synchronously. Adding async support would require either an
AsyncGraphOperations alternative or running the migration loop in a thread
pool, neither of which is in scope.
What to do: Call runic from a subprocess or thread if you need to run migrations from an async application:
import subprocess
result = subprocess.run(["runic", "upgrade"], check=True)
No parallel migration execution¶
What this means: runic applies revisions one at a time, in topological order. There is no mechanism to run independent branches in parallel.
Why: Parallel execution introduces ordering hazards (two branches creating conflicting indexes, for example). The correctness guarantees of a topological sort require sequential execution.
No automatic rollback on partial failure¶
What this means: If a migration script raises an exception mid-way (e.g., after creating one index but before creating a second), the database is left in a partially applied state. The version node is not updated (it remains at the prior revision), but the partial changes are not automatically undone.
The exception (FalkorDB only): Revisions with snapshot = True take a
full graph copy before running. On failure the snapshot is restored, leaving
the graph in its pre-migration state. This mechanism is not available on other
backends — an adapter advertises its capability via
supports_snapshots(). On a backend that does not support snapshots, a
snapshot = True revision logs a warning and runs without a snapshot
rather than failing.
Why: DDL operations are not transactional across the adapters runic supports. Automatic rollback of arbitrary Cypher is not possible.
DDL failures abort the migration (fail-fast): A failed schema operation
(create/drop index or constraint) raises and stops the migration on every
backend, so an incomplete schema is never silently reported as applied. Note
that the Bolt adapters differ in idempotency: Neo4j DDL uses
IF NOT EXISTS / IF EXISTS, but Memgraph and ArcadeDB index DDL do not.
Re-running a migration over an already-applied schema can therefore abort on
Memgraph/ArcadeDB; prefer snapshot = True (FalkorDB) or idempotent,
focused revisions on those backends.
Recommendation: Use snapshot = True (FalkorDB) for high-risk migrations
on production data. On other backends, prefer small, focused revisions that are
easy to reason about if they need to be re-run after a failure.
No --autogenerate without target_manifest¶
What this means: runic revision --autogenerate and runic check
both require target_manifest to be set in env.py via
context.configure(..., target_manifest=...). Without it they exit with
an error.
No runic.ini or TOML configuration file¶
What this means: runic has no .ini, pyproject.toml section, or
YAML config file. All configuration happens in env.py (a Python script).
Why: The pure-Python config keeps secrets out of committed config files
and removes a second config surface. Environment variables loaded by the
application’s own config system (dotenv, Pydantic Settings, etc.) are
accessible from env.py without any runic-specific glue.
No built-in online migration / zero-downtime helpers¶
What this means: runic does not provide primitives for online schema changes that must be applied in multiple steps to avoid locking (e.g., backfill-then-index patterns for large graphs under live traffic).
What to do: Model multi-step migrations as multiple sequential revisions.
Use op.run_cypher with LIMIT $batch (as op.rename_property does)
for large data changes that must proceed incrementally.