Docs

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.

With a Tetra account, your dashboard gives you the connection string and API key these commands need.

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 users becomes a (:User); columns become properties.
  • Primary key → node id. Keep the PK as an id property and put a uniqueness constraint on it.
  • Foreign key → relationship. orders.user_id becomes (: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).
  • psql access to your Postgres database.
  • Your CSVs reachable by Tetra's importer: a local file:/// path or a URL.

Steps

  1. Export each table to CSV

    Use \copy so 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;
  2. 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;
  3. Load the nodes

    One LOAD CSV per 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)});
  4. 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);
  5. 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 NULL so 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.

Want to see it on your own graph?

We'll walk you through TETRA on a graph shaped like yours. Or just tell us who you are and we'll keep you in the loop as we get closer to public launch.