Docs / Migrate
Migrating from PostgreSQL
Relational data maps cleanly onto a graph: tables become node labels, rows become nodes, and foreign keys become relationships. You export to CSV, load the nodes, then wire up the edges, and your joins turn into fast traversals.
Decide your model first
Most of the migration is a modelling decision. A good default for an e-commerce schema
(users, orders, products, order_items):
- Table → label. Each row in
usersbecomes a(:User); columns become properties. - Primary key → node id. Keep the PK as an
idproperty and put a uniqueness constraint on it. - Foreign key → relationship.
orders.user_idbecomes(:Order)-[:PLACED_BY]->(:User). - Join table → edge.
order_items(order_id, product_id, qty)becomes(:Order)-[:CONTAINS {qty}]->(:Product): its extra columns ride on the relationship. - Lookup / enum tables are often better as a property than a node. Migrate them as edges only if you query across them.
Before you start
- A Tetra database + your connection string and API key (free DEMO MODE is fine to trial).
psqlaccess to your Postgres database.- Your CSVs reachable by Tetra's importer: a local
file:///path or a URL.
Steps
Export each table to CSV
Use
\copyso the export runs client-side with headers.-- One CSV per table (psql meta-command): \copy users TO 'users.csv' CSV HEADER; \copy orders TO 'orders.csv' CSV HEADER; \copy products TO 'products.csv' CSV HEADER; \copy order_items TO 'order_items.csv' CSV HEADER;
Create constraints up front
Add a uniqueness constraint on each id before loading. It indexes the lookups your relationship step depends on.
CREATE CONSTRAINT FOR (u:User) REQUIRE u.id IS UNIQUE; CREATE CONSTRAINT FOR (o:Order) REQUIRE o.id IS UNIQUE; CREATE CONSTRAINT FOR (p:Product) REQUIRE p.id IS UNIQUE;
Load the nodes
One
LOAD CSVper table. CSV values are strings, so cast numbers and dates as you go.LOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row CREATE (:User {id: row.id, email: row.email, name: row.name}); LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row CREATE (:Order {id: row.id, total: toFloat(row.total), placed_at: datetime(row.created_at)}); LOAD CSV WITH HEADERS FROM 'file:///products.csv' AS row CREATE (:Product {id: row.id, name: row.name, price: toFloat(row.price)});Wire up the relationships
Re-read the same CSVs, match both endpoints by id, and create the edges, both from foreign keys and from join tables.
// Foreign key: orders.user_id → (:Order)-[:PLACED_BY]->(:User) LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row MATCH (o:Order {id: row.id}), (u:User {id: row.user_id}) CREATE (o)-[:PLACED_BY]->(u); // Join table: order_items → (:Order)-[:CONTAINS {qty}]->(:Product) LOAD CSV WITH HEADERS FROM 'file:///order_items.csv' AS row MATCH (o:Order {id: row.order_id}), (p:Product {id: row.product_id}) CREATE (o)-[:CONTAINS {qty: toInteger(row.qty)}]->(p);Verify
Node and relationship counts should reconcile against your source tables.
// Node counts by label — compare to SELECT count(*) per table: MATCH (n) RETURN labels(n)[0] AS label, count(*) AS n ORDER BY label; // Relationship counts: MATCH ()-[r]->() RETURN type(r) AS rel, count(*) AS n ORDER BY rel;
Tips & gotchas
- Cast types: CSV is all text, so wrap with
toInteger(),toFloat(),date()/datetime(). - Nullable FKs: guard the relationship load with
WHERE row.user_id IS NOT NULLso missing references don't create orphans. - Large tables: batch the load so it doesn't run as a single transaction.
// Big tables? Commit in batches so a load doesn't run as one transaction:
LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
CALL { WITH row
MATCH (o:Order {id: row.id}), (u:User {id: row.user_id})
CREATE (o)-[:PLACED_BY]->(u)
} IN TRANSACTIONS OF 10000 ROWS; - Don't over-normalise: a graph rewards traversal, so collapse pure join tables into edges and keep one-to-one detail as properties.
Next steps
Connect a driver or your AI assistant from the dashboard, or see Migrating from Neo4j · MongoDB.