How to Store a Knowledge Graph in a Database (A Hands-On Guides)

1. Why This Matters in Production

“It’s not the data that’s hard — it’s making sense of how it connects.”

I’ve worked with enough production ML systems to say this with confidence: sooner or later, you’ll need to model not just data points, but relationships. And not in a vague, philosophical way — I mean actual links between entities, timestamped actions, evolving hierarchies. That’s where knowledge graphs earn their keep.

Personally, I’ve used them in fraud detection workflows where relationships between users, devices, IPs, and transactions told the real story — not the individual features. You could never have caught the fraud patterns with a flat table. But here’s the catch: designing a knowledge graph is one thing — storing it properly is where most teams hit a wall.

This post isn’t about what a knowledge graph is. You already know that. I’m going to show you how I store knowledge graphs in real systems — with real tools, real code, and all the messy little things that come up when theory hits production.


2. Pre-Requisites and Assumptions

Let’s skip the basics — if you’re reading this, I’ll assume you already know your RDFs from your property graphs, you understand how graph traversal works, and you’ve at least touched SPARQL, Cypher, or recursive SQL.

I’m not going to walk you through graph theory or database fundamentals — I’m focusing purely on implementation, especially the parts I’ve seen teams struggle with firsthand.

In this guide, I’ll be covering practical setups using the following stacks (all of which I’ve used personally depending on the project context):

  • Neo4j: My go-to for flexible, property graph modeling when I need expressive traversal and solid tooling.
  • PostgreSQL: Yes, relational — but with JSONB, recursive CTEs, and extensions like pg_graph or pgvector, it punches way above its weight.
  • AWS Neptune: RDF and Property Graph support, plus it plays well in cloud-native pipelines — I’ve used this when scaling was a hard requirement.
  • Apache Jena / RDFLib: When you’re working on ontology-heavy graphs or need full RDF compatibility (think linked data or schema reasoning).

If your stack includes these tools — or you’re planning to move a graph project into production — this guide should save you a lot of dead ends.


3. Choosing the Right Storage Backend

“Don’t start with the tool. Start with the shape of your data.”

I’ve made this mistake myself — jumping into a graph DB just because it was trending, only to realize it didn’t suit the use case. So here’s how I personally approach backend selection, based on how the graph is meant to work in production. I’m not comparing tools just to name-drop — I’m sharing the ones I’ve actually used in production systems, where uptime and latency weren’t optional.

For Property Graphs: Neo4j

If you need flexible schemas, fast traversals, and rich relationship properties, Neo4j is solid. Personally, I reach for it when the graph structure isn’t rigid and evolves over time — like in product knowledge graphs or dynamic social networks.

Why I use it:

  • Excellent Cypher query language (intuitive, and yes, expressive).
  • Native graph storage — not bolted on.
  • Production tooling is mature: monitoring, backups, role-based access.

Docker Setup (My Standard Config):

docker run \
  --name neo4j \
  -p 7474:7474 -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/test123 \
  -v $HOME/neo4j/data:/data \
  neo4j:5.16

Once it’s up, I usually connect through py2neo or the official Python driver. If you’re doing programmatic ingestion, you’ll want that ready from day one.

For RDF Triple Stores: Apache Jena TDB or Blazegraph

This might surprise you: in use cases that required ontology-driven design or semantic reasoning (like regulatory data or biomedical applications), I found RDF stores far more aligned.

When I worked on a legal document graph, I used Jena TDB — the ability to use custom vocabularies and SPARQL for inference saved me a ton of preprocessing work.

Apache Jena Setup (Local Project Skeleton):

# Assuming Java & Maven are installed
git clone https://github.com/apache/jena.git
cd jena/jena-fuseki2
./fuseki-server

Or with Docker (easier for most):

docker run -d \
  --name fuseki \
  -p 3030:3030 \
  stain/jena-fuseki

SPARQL is powerful once you get past the syntax. Personally, I use it when I need tight control over RDF vocabularies and the relationships follow strict ontologies.

For Hybrid or Tabular+Graph Use Cases: PostgreSQL with JSONB + Extensions

Now this is one I’ve used when I couldn’t get buy-in for a dedicated graph DB — usually in legacy environments. PostgreSQL, when used right, can absolutely handle knowledge graphs. It’s not as elegant, but it works surprisingly well if you’re clever with JSONB, recursive CTEs, and something like pg_graph or even just adjacency tables.

Why I’ve used it:

  • When integrating graph-like data into existing analytics pipelines
  • When the infra team already had PostgreSQL hardened and secured
  • When we needed joins, aggregations, and OLAP-style reporting on top

Sample Setup (Docker, lightweight):

docker run --name pg-graph \
  -e POSTGRES_USER=admin \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=graphdb \
  -p 5432:5432 \
  -v $HOME/pgdata:/var/lib/postgresql/data \
  postgres:16

After that, I usually install the pg_graph extension or model the graph manually using tables like nodes(id, type, payload) and edges(from_id, to_id, rel_type, metadata).

For programmatic access, I prefer using SQLAlchemy or direct psycopg2 for better transaction control during ingestion.

In short:

  • Need dynamic, real-world traversals? → Go with Neo4j
  • Need strict ontologies or reasoning? → Use Jena or Blazegraph
  • Need to integrate with relational data? → PostgreSQL works, with effort

And here’s the thing — I’ve used all three depending on the organization’s constraints. There’s no one “best” — but there is a best-fit for the graph you’re actually building.


4. Schema Design for Knowledge Graphs

“If your schema feels too clean, you probably haven’t hit production yet.”

Designing the schema is where I’ve seen the most debate — and ironically, the least testing. In my own projects, I’ve learned to treat schema design not as a one-time diagramming exercise, but as a working contract between your data, your queries, and your team.

Here’s how I approach it when things get real.

1. Nodes: Entities That Actually Matter

You don’t need to model everything as a node. I learned this the hard way when a team I worked with tried to turn every noun into an entity. Trust me — that’s how you end up with a bloated, unreadable graph.

Personally, I keep it to core identity-carrying entities — the kind you’d put in an audit log or show on a UI:

  • Person
  • Company
  • Product
  • Event
  • Device

Here’s what that looks like in Neo4j:

CREATE (:Person {id: "user_123", name: "Alice"})-[:BOUGHT_AT {timestamp: 1702500000}]->(:Product {id: "prod_456", name: "Laptop"})

In PostgreSQL, I’d model these as JSONB-heavy tables with well-defined primary keys:

CREATE TABLE persons (
    id TEXT PRIMARY KEY,
    data JSONB
);

CREATE TABLE products (
    id TEXT PRIMARY KEY,
    data JSONB
);

2. Relationships: Add Context, Not Just Arrows

This might sound obvious, but I’ve seen plenty of graphs where the relationships are just “CONNECTED_TO” with no payload. That’s not useful. In production, edges need to carry meaning — timestamps, source system, maybe even confidence scores.

Here’s how I usually define them in Neo4j:

(:Person {id: "u1"})-[:LOGGED_IN_FROM {ip: "192.168.1.5", time: "2024-12-01T14:03:00"}]->(:Device {id: "dev_55"})

In Postgres, I do it like this — an edge table with metadata:

CREATE TABLE edges (
    from_id TEXT,
    to_id TEXT,
    rel_type TEXT,
    metadata JSONB,
    PRIMARY KEY (from_id, to_id, rel_type)
);

This gives you flexibility when querying and indexing, especially for relationship-heavy graphs.

3. Ontologies: Reuse vs Roll Your Own

Here’s the deal: I’ve reused existing ontologies when the domain called for it (like FOAF, Dublin Core, or schema.org), but I don’t force it when I’m working on something proprietary or domain-specific. You can waste weeks trying to shoehorn your data into someone else’s vocabulary — I’ve been there.

If you do use RDF and care about interoperability or reasoning, here’s a snippet I’ve used before using Turtle:

@prefix ex: <http://example.org/> .

ex:user_123 a ex:Person ;
    ex:worksAt ex:company_456 ;
    ex:name "Alice" ;
    ex:email "alice@example.com" .

And yes, I’ve also defined custom vocabularies like ex:isFraudLinkedTo and ex:eventOccurredOn — because sometimes, only your graph knows what’s really going on.

Final Thought on Schema Design

One trick I use a lot: design for your top 5 queries — not just your data model. I literally sketch out the Cypher or SPARQL queries I know the app or ML model will need, then I tweak the schema to make those efficient. Schema isn’t just about structure — it’s about performance.


5. Ingesting Data into the Graph

“Your graph is only as useful as the data you manage to shove into it — cleanly and consistently.”

This part is where I’ve personally spent a lot of debugging hours — not in modeling the graph, but in writing ingestion pipelines that don’t choke on edge cases, malformed timestamps, or inconsistent entity IDs.

Let me walk you through how I’ve handled ingestion across different backends. No fluff — just code and experience.

a. Neo4j: LOAD CSV + Python Ingestion

LOAD CSV — The Fastest Way to Prototype

When I just need to hydrate a graph quickly (especially in dev or POCs), I lean on LOAD CSV. Here’s how I typically do it:

LOAD CSV WITH HEADERS FROM 'file:///people.csv' AS row
MERGE (p:Person {id: row.id})
SET p.name = row.name, p.email = row.email;

For relationships:

LOAD CSV WITH HEADERS FROM 'file:///logins.csv' AS row
MATCH (u:Person {id: row.user_id}), (d:Device {id: row.device_id})
MERGE (u)-[:LOGGED_IN_FROM {time: row.timestamp}]->(d);

Pro tip: If you’re dealing with hundreds of thousands of rows — always MERGE on indexed fields. Otherwise, this gets ugly fast.

Python: Using neo4j Driver (or py2neo)

For production-grade ingestion (especially when batch sizes matter), I’ve moved to neo4j’s official driver.

from neo4j import GraphDatabase

driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "pass"))

def ingest(tx, person):
    tx.run("""
        MERGE (p:Person {id: $id})
        SET p.name = $name
    """, id=person["id"], name=person["name"])

with driver.session() as session:
    for row in data:
        session.write_transaction(ingest, row)

Batch in groups of 500–1000 if your input is large — I’ve seen huge performance gains with that tweak.

b. PostgreSQL: Flatten + Graph Extract

You might be wondering: how do you even “graph” in Postgres?

Here’s what’s worked well for me.

Flattening JSON Into Tables

Let’s say you’re ingesting a raw JSON file with nested user-product interactions. My go-to pattern uses jsonb_to_recordset inside a CTE:

WITH payload AS (
  SELECT jsonb_array_elements('[{"user_id": "u1", "product_id": "p1"}, ...]') AS row
)
INSERT INTO user_product_edges (user_id, product_id)
SELECT row->>'user_id', row->>'product_id'
FROM payload;

This gives you flexibility — especially if you’re planning to build adjacency lists or edge tables for graph analysis later.

Storing Edges

I usually do this:

CREATE TABLE user_product_edges (
    user_id TEXT,
    product_id TEXT,
    event_type TEXT,
    metadata JSONB
);

Why? Because it’s easy to query in both directions, and you can augment it with weights or timestamps later.

c. RDF Store: SPARQL & Command-line Ingestion

RDF ingestion can feel more brittle — but once the tooling is set up, it scales surprisingly well.

SPARQL INSERT Example

For small-scale manual inserts (or programmatic control), this works:

INSERT DATA {
  <http://example.org/person/alice> a <http://xmlns.com/foaf/0.1/Person> ;
      <http://xmlns.com/foaf/0.1/name> "Alice" ;
      <http://example.org/worksAt> <http://example.org/company/acme> .
}

I’ve mostly used this for tests, or in CI setups to seed fixtures.

Bulk Load with Apache Jena’s riot CLI

In production, I rarely insert line-by-line. I use Jena riot to load RDF files into a TDB or Fuseki instance.

riot --output=nt mydata.ttl > mydata.nt
tdbloader2 --loc /path/to/tdb mydata.nt

If you’re using RDFLib in Python, this is my go-to snippet:

from rdflib import Graph

g = Graph()
g.parse("mydata.ttl", format="turtle")
g.serialize(destination="mydata.rdf", format="xml")

One gotcha I ran into: always validate your TTL files. riot --validate has saved me countless hours chasing silent ingest errors.

Final Notes on Ingestion

What I’ve learned: no matter the engine, schema and ingestion are joined at the hip. You can’t design one in isolation. Run ingest scripts early — even with mock data — just to feel out what breaks. And always — always — track your failed records and logs.


6. Querying the Knowledge Graph

“A graph that can’t answer smart questions is just a fancy data dump.”

I’ve had to design queries that do more than just fetch nodes — I’m talking about multi-hop traversals, entity disambiguation, and even reasoning across ontologies. This section is about squeezing intelligence out of your graph — no matter the backend.

Neo4j: Cypher for Deep Traversals and Recommendations

When I first started working with Neo4j, I underestimated how expressive Cypher could be. Here’s something I use for multi-hop friend-of-friend traversal — with depth limits.

MATCH (a:Person {id: "u123"})-[:FRIEND_OF*2..5]->(b:Person)
RETURN DISTINCT b.name, b.id

This might surprise you: even with large networks, as long as your FRIEND_OF edges are indexed, this query can be surprisingly fast.

Graph-based Recommendations

Here’s a stripped-down pattern I’ve actually used for a product recommendation system:

MATCH (me:User {id: "u123"})-[:PURCHASED]->(p:Product)<-[:PURCHASED]-(other:User)-[:PURCHASED]->(rec:Product)
WHERE NOT (me)-[:PURCHASED]->(rec)
RETURN rec.name, count(*) AS score
ORDER BY score DESC
LIMIT 10

You’re essentially asking the graph: “What did people similar to me buy, that I haven’t touched yet?” Works like a charm.

PostgreSQL: Recursive CTEs for Hierarchies

If you’re using Postgres to emulate a graph (which I’ve done more than once when Neo4j wasn’t an option), recursive CTEs are your friend.

Example: Org Chart Traversal

WITH RECURSIVE hierarchy AS (
  SELECT id, manager_id, name, 1 AS depth
  FROM employees
  WHERE id = 'e001'

  UNION ALL

  SELECT e.id, e.manager_id, e.name, h.depth + 1
  FROM employees e
  JOIN hierarchy h ON e.manager_id = h.id
)
SELECT * FROM hierarchy;

I’ve used this exact pattern in fraud detection — tracing how shell companies are layered under a single director.

Entity Disambiguation? Absolutely.

It’s not always about traversal — sometimes you want to ask, “Which John Smith am I really dealing with?”

Here’s a quick fuzzy match + metadata filter:

SELECT *
FROM people
WHERE name ILIKE '%john smith%'
AND jsonb_extract_path_text(metadata, 'location') = 'New York';

Simple, but very effective in narrowing down candidates before pushing into a disambiguation model.

SPARQL: Inference and Federated Queries

I’ll be honest — SPARQL felt clunky to me at first. But when you’re working with ontologies and reasoning, it’s hard to beat.

Federated Query Example

SELECT ?person ?affiliation
WHERE {
  SERVICE <http://dbpedia.org/sparql> {
    ?person dbo:birthPlace dbr:Berlin .
    ?person dbo:affiliation ?affiliation .
  }
}

This lets you hit external SPARQL endpoints like DBpedia or Wikidata. I’ve used this to enrich internal KGs with external facts — pretty powerful.

Ontology-Driven Reasoning

If your triple store supports it (like OWL2 with Jena or Stardog), you can do implicit inference:

SELECT ?x
WHERE {
  ?x a :GraduateStudent .
}

Even if :GraduateStudent is a subclass of :Student, you’ll still get matches — thanks to reasoning.

Real-World Use Cases

Let me just throw out a few places where these patterns saved me:

  • Entity Disambiguation: Filtering candidates down by graph context before passing to NLP.
  • Recommendations: Graph hops + frequency scoring → surprisingly solid cold-start strategy.
  • Inference: Deriving roles or relationships not explicitly present — just by relying on class hierarchies.

7. Indexing and Performance Optimization

“You can have the most beautiful schema and queries in the world — but if your performance sucks, nobody cares.”

That’s not theory. I’ve been in those late-night ops calls where a Cypher query took 14 minutes and brought the system to its knees — only to realize we’d forgotten to index one stupid property.

So let me walk you through what I actually do to make sure performance is built in from day one.

Neo4j: Targeted Indexes and Query Profiling

In Neo4j, indexes aren’t just nice-to-have — they make or break performance, especially when filters are involved.

Create Index on Property

CREATE INDEX user_email_index FOR (u:User) ON (u.email);

If you’re filtering on email often, don’t rely on Neo4j to magically optimize it — help it out.

Composite Index (When Matching on Multiple Props)

CREATE INDEX user_composite_index FOR (u:User) ON (u.firstName, u.lastName);

I’ve used this exact setup in a real-time personalization engine — cutting query time from 2s to under 200ms.

Don’t Guess — PROFILE It

Before I roll anything to production, I run this religiously:

PROFILE
MATCH (u:User)-[:PURCHASED]->(p:Product)
WHERE u.email = "john@example.com"
RETURN p.name

You’ll see how the planner executes each step. If there’s a label scan where you expected an index seek — you’ve missed something.

PostgreSQL: GIN, B-Tree, and CTE Optimizations

PostgreSQL is a beast if you tune it right. I’ve worked with hybrid JSONB + edge-table setups, and here’s what helped me squeeze the most out of it.

GIN on JSONB Columns

CREATE INDEX idx_metadata ON people USING gin (metadata);

Perfect for when you need to slice/filter on semi-structured fields.

B-Tree on Relational Edge Tables

If you’re storing edges as rows:

CREATE INDEX idx_edges_source_target ON edges (source_id, target_id);

You need this when traversing large edge tables — especially if you’re doing recursive joins or materialized view precomputations.

EXPLAIN ANALYZE is Your Truth Serum

This has saved me more times than I can count:

EXPLAIN ANALYZE
SELECT * FROM edges
WHERE source_id = 'n_1002';

I once found an unoptimized query that was scanning every single edge across 100M+ rows — fixed in 5 minutes with the right index.

SPARQL: Predicate Indexing and Named Graph Strategies

Now SPARQL optimization isn’t always as transparent — but here’s what I rely on when working with Jena or Blazegraph.

Predicate Indexing

If you’re querying specific predicates often (foaf:name, rdf:type), make sure your store supports predicate-level indexing — some do this out of the box (like Blazegraph), others need tuning via config.

Named Graphs for Scope Control

Here’s the deal: breaking your triples into named graphs is huge for performance.

Instead of running queries over the entire universe, you can isolate queries to a subset:

SELECT ?s ?p ?o
FROM NAMED <http://example.org/graph/userdata>
WHERE {
  GRAPH <http://example.org/graph/userdata> {
    ?s ?p ?o .
  }
}

I’ve used this to separate knowledge layers (factual vs. inferred vs. user-generated), and it made queries 3–4x faster.

Caching & Pre-Materialization

For high-read scenarios (like dashboards or real-time APIs), I’ve gone further:

  • Neo4j: Write key query results to in-memory structures or pre-materialize relationships.
  • Postgres: Use materialized views for common joins or paths — refresh them hourly/daily.
  • SPARQL: Build reasoning outputs once (via Jena inference engine) and store as static graphs.
Quick Benchmarking Example

Here’s one I used just last month for Neo4j query benchmarking:

PROFILE
MATCH (a:Person)-[:FRIEND_OF*2..3]->(b:Person)
WHERE a.id = "p_223"
RETURN b.name

I tracked memory, db hits, and runtime before/after adding a composite index — shaved off nearly 70% execution time.

When you’re dealing with production-scale graphs, performance isn’t a “nice bonus” — it’s part of the schema design. I’ve made the mistake of postponing it before… and paid in latency, cost, and some very awkward client calls.


8. Versioning and Updates in Knowledge Graphs

Graphs aren’t static — not in the real world. People change jobs. Products get discontinued. Facts evolve. So if you’re not planning for temporal truth and update semantics, your graph will betray you.

Soft Deletes and Immutable Relationships

Here’s the deal: I never delete anything outright in my graphs. Seriously. When something “ends” — a job, a relationship, a status — I mark it, I don’t destroy it.

In Neo4j:

Let’s say you’ve got this relationship:

MATCH (p:Person)-[r:WORKS_AT]->(c:Company)
WHERE p.id = "p_104"
SET r.active = false, r.endedAt = timestamp()

This approach lets you retain the relationship’s history — super useful when tracking entity evolution or building time-aware features.

And if you want to add a new relationship instead of overwriting:

MATCH (p:Person {id: "p_104"}), (c:Company {id: "c_302"})
CREATE (p)-[:WORKS_AT {active: true, startedAt: timestamp()}]->(c)

Now you’ve got a full relationship timeline. I’ve used this for employee movement graphs, and it worked beautifully with Cypher’s temporal queries.

Timestamped Facts — aka Bi-Temporal Graphs

This might surprise you: your graph probably needs both event time and system time.

I use valid_from / valid_to for real-world validity, and recorded_at / archived_at for internal tracking. It sounds subtle, but it’s saved me in audit-heavy environments.

Postgres Example (Edge Table):

CREATE TABLE employment_edges (
  person_id UUID,
  company_id UUID,
  role TEXT,
  valid_from TIMESTAMP,
  valid_to TIMESTAMP,
  recorded_at TIMESTAMP DEFAULT NOW(),
  archived_at TIMESTAMP
);

Now, when a role ends:

UPDATE employment_edges
SET valid_to = '2024-12-31', archived_at = NOW()
WHERE person_id = 'uuid_p1' AND company_id = 'uuid_c1' AND archived_at IS NULL;

I’ve used this model to build temporal slicing in graph analytics — like what a person’s org chart looked like last year.

RDF Reification & Named Graphs for Change Tracking

RDF makes things a bit tricky. You can’t just attach metadata to a triple. But here’s how I’ve handled versioning:

Option 1: Named Graphs

Every version lives in its own graph:

GRAPH <http://example.org/version/2023> {
  :bob :worksAt :acme .
}
GRAPH <http://example.org/version/2024> {
  :bob :worksAt :globex .
}

Then you can query by snapshot:

SELECT ?company WHERE {
  GRAPH <http://example.org/version/2023> {
    :bob :worksAt ?company .
  }
}

Option 2: Reification (Painful but Precise)

_:stmt1 a rdf:Statement ;
  rdf:subject :bob ;
  rdf:predicate :worksAt ;
  rdf:object :acme ;
  :validFrom "2022-01-01"^^xsd:date ;
  :validTo "2023-12-31"^^xsd:date .

I only use this when temporal accuracy outweighs query simplicity — and yes, it bloats your triple count fast.

Maintaining Change Logs

Whatever backend you use, you’ll need a place to track why something changed — not just what.

In Neo4j:

CREATE (:ChangeLog {
  entity: "p_104",
  changeType: "RelationshipEnded",
  reason: "User terminated employment",
  timestamp: timestamp()
})

I usually set up a trigger or post-transaction hook to do this automatically when key properties change.

In Postgres:

CREATE TABLE change_log (
  entity_id UUID,
  change_type TEXT,
  reason TEXT,
  changed_at TIMESTAMP DEFAULT NOW()
);

Wrapping Up

Versioning isn’t optional — not if your graph touches real-world entities. I’ve seen too many graphs fall apart because someone assumed they’d never need to know what used to be true.

And when you build it right — with temporal logic and change logs — suddenly your graph isn’t just a snapshot. It’s a timeline. A story of everything your data has gone through.


9. Exporting and Interoperability

There’s a saying I keep coming back to: “If your data can’t travel, it’s not knowledge — it’s a trap.” That’s especially true with graphs. Getting your data out cleanly — in the right shape and format — is just as important as getting it in.

Exporting Formats I’ve Actually Used

When it comes to serialization, I’ve had to support teams across different systems — some wanted RDF/XML, others preferred JSON-LD, and yes, even good ol’ CSV for analytics workflows.

Here’s how I usually handle this.

Neo4j Export Examples

To CSV (for tabular tools or pipelines)

CALL apoc.export.csv.query(
  "MATCH (p:Person)-[r:WORKS_AT]->(c:Company) RETURN p.name, c.name, r.since",
  "export/employment.csv",
  {}
)

You can also dump the entire graph if needed:

CALL apoc.export.csv.all("export/full_graph.csv", {})

I’ve used this for piping data into pandas or cleaning it in dbt before graph re-ingestion.

To JSON (for interop or backups)

CALL apoc.export.json.all("export/graph.json", {})

For RDF output, Neo4j doesn’t support native RDF — so I usually run a transform layer using neosemantics (n10s).

CALL n10s.rdf.export.stream("Turtle")
YIELD triples

Personally, I only go this route when I need to feed data into RDF stores like Blazegraph or Jena.

SPARQL-Based RDF Exports

If you’re in the RDF world, you’re in luck — triple stores are built for serialization.

Turtle Export with Apache Jena

From the command line:

riot --output=TURTLE input.rdf > output.ttl

And from Python (with rdflib):

from rdflib import Graph

g = Graph()
g.parse("input.rdf")
g.serialize("output.ttl", format="turtle")

Cross-DB Syncing (Yes, I’ve Done Neo4j → Postgres)

This part is tricky, especially when syncing edge-based schemas into normalized SQL.

Let me show you a stripped-down example I’ve used for syncing people and relationships from Neo4j into Postgres tables.

from neo4j import GraphDatabase
import psycopg2

# Neo4j query
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
cypher = "MATCH (p:Person)-[r:FRIEND_OF]->(f:Person) RETURN p.name, f.name, r.since"

# Postgres insert
conn = psycopg2.connect(dbname="mydb", user="user", password="pass")

with driver.session() as session, conn.cursor() as cursor:
    results = session.run(cypher)
    for row in results:
        cursor.execute(
            "INSERT INTO friendships (person_a, person_b, since) VALUES (%s, %s, %s)",
            (row["p.name"], row["f.name"], row["r.since"])
        )
    conn.commit()

This worked well for lightweight graph syncs. For real-time syncing, I’d recommend using Kafka + Debezium (but that’s another beast entirely).


10. Deploying in Production

Now let’s talk about moving beyond the notebook. Once your graph’s ready, how you deploy it makes all the difference — especially when you scale.

I’ll walk you through some things I’ve personally deployed in real-world projects.

Storage: On-Disk vs In-Memory

You might be wondering: Should I keep my graph in RAM or on disk? Here’s my take:

  • On-disk is fine for large graphs you query sporadically. It’s stable, persistent, and easier to manage.
  • In-memory shines when you need lightning-fast reads (e.g., recommendation engines, real-time path queries). I’ve used RedisGraph for this — but it came at the cost of persistence and tooling.

In most production systems, I lean toward disk-backed databases with good caching (Neo4j does this well by default).

Docker Compose for Graph Deployment

This is how I usually bootstrap graph services locally or on lightweight infra:

version: '3.8'
services:
  neo4j:
    image: neo4j:5.14
    ports:
      - "7474:7474"
      - "7687:7687"
    environment:
      NEO4J_AUTH: neo4j/test
    volumes:
      - ./neo4j_data:/data

That volume mount ensures persistence — I’ve learned the hard way not to ignore that during early prototyping.

Kubernetes Deployment (Helm)

For larger setups, I’ve deployed Neo4j clusters using Helm:

helm repo add neo4j https://helm.neo4j.com/neo4j
helm install my-graph neo4j/neo4j \
  --set acceptLicenseAgreement=yes \
  --set neo4j.password=securepass

For managed hosting, I’ve worked with Neo4j Aura and AWS Neptune. Both were decent, but each came with quirks (e.g., Neptune’s limited SPARQL support and no Cypher).

Leave a Comment