Skip to main content
A minimal guide to how Cognee talks to a graph database without your code ever depending on which one is actually configured. You’ll create two nodes and an edge, read them back, and clean up — using only interface methods, on an isolated local graph.

Before You Start

  • This is a low-level infrastructure exercise, not a replacement for cognee.remember() or Cognee pipelines — direct graph writes like this skip vector indexing, relational records, dataset provenance, and normal access-control behavior.
  • This guide defines a custom Person(DataPoint) model — see DataPoints for the concept, or Custom Data Models and Custom Graph Model to go deeper.

Code in Action

What Just Happened

Step 1: Configure an Isolated Graph and Get the Engine

graph_file_path points Ladybug at a throwaway directory so this exercise never touches your default Cognee graph. graph_database_subprocess_enabled=False keeps Ladybug running inside your Python process instead of as a separate background process — simpler for a short script like this. get_graph_engine() is async and returns whatever adapter the configuration selected — here, a Ladybug adapter — typed as GraphDBInterface. Everything from this point on is written against that interface, not against Ladybug specifically.

Step 2: Define a Minimal Node Model

Person inherits from DataPoint, Cognee’s base model for graph nodes. Declaring "identity_fields": ["name"] gives each Person a deterministic id based on its name, so Person(name="Alice") always resolves to the same node id instead of a random one.

Step 3: Create Nodes and an Edge

add_nodes(nodes) takes a list of DataPoint instances and writes them as one bulk operation. The Ladybug, Postgres, and Turso adapters split a large list into several idempotent bulk statements rather than sending one statement per call, so a single write cannot outgrow the backend’s per-statement limits; the chunk size is an internal, per-adapter detail and is not configurable. add_edge(source_id, target_id, relationship_name, edge_properties) creates one directed edge — here, Alice -[knows]-> Bob with a small property dictionary. Both take plain string ids, so DataPoint.id (a UUID) is converted with str(...) before being passed in.

Step 4: Read Data Back

get_node(node_id) returns a single node’s properties as a dictionary, or None if it does not exist. get_neighbors(node_id) returns the properties of every node connected to the given node — here, Bob’s only neighbor is Alice. has_edge(source_id, target_id, relationship_name) checks whether a specific directed, labeled edge exists, returning a plain bool.

Step 5: Clean Up

delete_nodes(node_ids) removes the listed nodes and any edges attached to them. Running it in a finally block ensures the example nodes are removed even if an earlier step raises, leaving the isolated graph empty again.

One Interface, Many Databases

Cognee can store its graph in several different backends — Ladybug (the default local, file-based engine), Postgres, Neo4j, and others (see Graph Stores for how to configure each one). Application code that queries or writes to the graph should not need a different code path for each one. Cognee solves this with three pieces:
  • Configuration picks which provider is active (e.g. "ladybug" or "postgres_demo").
  • get_graph_engine() is a factory function. It reads the configuration and returns an object implementing the interface — you never construct an adapter yourself.
  • GraphDBInterface is an abstract base class that declares the methods every adapter must provide (add_node, get_node, has_edge, and so on).
  • Concrete adapters (LadybugAdapter, PostgresDemoAdapter, …) implement that interface against a specific database.
Because every adapter satisfies the same interface, code written against get_graph_engine() works unchanged no matter which provider is configured.

Seed selection: get_top_degree_node_ids

GraphDBInterface.get_top_degree_node_ids(top_k) — the method that picks seed nodes for the default graph visualization — is declared on the interface but, unlike most of its methods, is not abstract: it ships with a working inherited implementation, so an adapter that predates it keeps loading. Its contract:
  • It returns node ids only (list[str]), never node objects or degree counts.
  • It is approximate by contract: an adapter may sample instead of counting exactly, and the order of near-equal nodes may vary between calls. Do not treat the result as a ranking — only as “up to top_k nodes worth starting from”.
  • Isolated nodes are valid seeds, so a graph with nodes but no edges still yields a non-empty result.
  • top_k must be ≥ 1; a non-positive value raises ValueError before the graph is read.
Ladybug, Neo4j, Neptune, Postgres, and Turso override it and do the ranking inside the store, over a bounded sample of at most 200,000 edges (Postgres and Turso materialize that sample once and reuse it for both edge directions). That bounds the aggregation and the returned data; it is not a latency or query-planning guarantee. An adapter that does not override get_top_degree_node_ids — a third-party adapter from cognee-community, for instance — still works: it inherits the default, which reads the whole graph and counts degree in Python. That is O(graph) in memory and can exhaust a worker on a multi-million-node graph, so the fallback logs a warning once per adapter type per process:
If you maintain an adapter and expect large graphs, override the method with a bounded, in-store implementation rather than moving the exact count into the database — an exact aggregate is expensive too. Adding a new graph database covers the signature alongside the other methods a new adapter implements.

Entity typing: get_entity_type_names

GraphDBInterface.get_entity_type_names(entity_ids) follows the same optional-override pattern: declared on the interface, not abstract, so an adapter written before it keeps loading. It is the one-hop is_a lookup that lets a bounded visualization read label entities whose EntityType node falls outside the window. Its contract:
  • It takes a list[str] of entity ids and returns dict[str, str], keyed by entity id, whose values are the name of the EntityType each entity points at through is_a.
  • An entity with no is_a edge — or one whose type node has no name — is simply absent from the result; it is not a key with an empty value.
  • An empty input list returns {} without touching the store.
  • It reads one hop outward from the given entities only, never from a type node outward.
Ladybug, Neo4j, Postgres, and Turso override it with a native query that reads just the is_a edge and the target’s name. Every other adapter — Neptune, and community adapters — inherits the default, which goes through get_neighborhood(node_ids=entity_ids, depth=1, edge_types=["is_a"]) and therefore hydrates the entities with all their properties and returns every edge among the returned nodes. That is heavier than the native queries but bounded by the entities asked about, so unlike the get_top_degree_node_ids fallback it logs no warning. If you override it, read only the is_a edges and the target names rather than hydrating whole neighborhoods. The caller also tolerates an adapter that raises: a failed lookup is logged once and skipped for the rest of that read instead of aborting it.

Counting: get_graph_counts

GraphDBInterface.get_graph_counts() follows the same optional-override pattern: declared on the interface, not abstract, so an adapter written before it keeps loading. Its contract:
  • It takes no arguments and returns a (num_nodes, num_edges) tuple of two ints — the counts and nothing else. No mean degree, no connected components, no density. For those, call get_graph_metrics(include_optional=...), which is unchanged.
  • On an empty graph, the Ladybug, Neo4j, Postgres, and Turso overrides return (0, 0) rather than raising.
Ladybug, Neo4j, Neptune, Postgres, and Turso all override it with aggregations that never materialize the rows: Ladybug and Neo4j count in Cypher (count(n) and count(r)), Postgres and Turso with SELECT count(*) over their node and edge tables, Neptune with a single model-independent aggregation. The Neo4j override exists precisely because the alternative does not scale — collecting the nodes or edges to take len() of them builds the whole graph inside one transaction and exhausts Neo4j’s transaction memory pool on a large graph. An adapter that does not override it still works: it inherits the default, which calls get_graph_metrics(include_optional=False) and reads the two counts out of the result. That is correct but needlessly expensive, because computing those metrics also walks the connected components. The fallback logs a warning once per adapter type per process:
The method exists for callers that only ever needed the two numbers: the dataset graph-summary path (GET /api/v1/datasets/graph-summary and GET /api/v1/visualize/brains-summary) counts each dataset’s graph once per cognify run, caches the result against that run id, and never asks for the rest of the metrics.

Write failures: Neptune’s bulk fallback

The interface says nothing about how an adapter performs a bulk write, and Neptune’s answer is a fallback: when the bulk query behind add_nodes, delete_nodes or add_edges fails (for add_edges, one query per relationship type), it retries that query’s items one at a time. What that fallback does with a per-item failure is visible through the interface, so it is worth knowing:
  • Each of the three methods collects the items that failed individually and, if the list is non-empty, raises one RuntimeError naming the counts — Failed to add 3 of 12 nodes after bulk fallback. The first underlying error is chained with raise ... from, so it is available as __cause__.
  • The error is raised once, after every item has been attempted — not at the first failure — so the items that can be written are, and stay written.
  • has_edge raises RuntimeError("Failed to check edge existence: ...") when the check itself errors, rather than returning False. Treat a bool from it as an answer about the graph, not as “no error occurred”.
This applies to both "neptune" and "neptune_analytics", which share these methods. Other adapters are unaffected — this is adapter-specific behavior, not an interface-wide contract — and cognee wraps none of these calls in a retry, so a caller that wants one supplies it.
Do not import or instantiate LadybugAdapter, PostgresDemoAdapter, or any other adapter directly. Always go through get_graph_engine() — adapter modules and class names are internal and do move (the Postgres adapter was renamed to PostgresDemoAdapter and its old module removed), while code written against get_graph_engine() keeps working unchanged.

Comparing Adapters

LadybugAdapter and PostgresDemoAdapter both implement add_nodes, add_edge, get_node, get_neighbors, and has_edge from GraphDBInterface — but the two implementations look nothing alike internally. Ladybug builds parameterized Cypher-style statements against an embedded Kuzu database; Postgres issues SQL against relational tables that model nodes and edges. Neither difference is visible to code written against the interface, which is the point: swapping graph_database_provider from "ladybug" to "postgres_demo" (with matching connection settings) does not require changing any of the code above.
Every adapter also has a raw query() method for running commands written directly in that database’s own language — Cypher for Ladybug/Neo4j, SQL for Postgres. Using it ties your code to one specific database, which is exactly what this guide is trying to avoid. That’s why it’s left out here — everything above uses only the shared GraphDBInterface methods, which work the same way no matter which database is configured.

DataPoints

Learn more about DataPoint, the base model Person builds on

BaseRetriever Guide

See the same abstract-contract pattern applied to retrievers