Your First Neo4j Schema Audit: A Complete Worked Example
Take a small graph — three labels, a handful of properties, three relationship types. By the end of this article that graph has a governed model in CoreModels and a recorded baseline audit that tells you, in machine-readable form, exactly what is under governance and where the graph is structurally weak. Total effort: two Cypher statements and two HTTP calls.
Your First Neo4j Schema Audit: A Complete Worked Example
Take a small graph — three labels, a handful of properties, three relationship types. By the end of this article that graph has a governed model in CoreModels and a recorded baseline audit that tells you, in machine-readable form, exactly what is under governance and where the graph is structurally weak. Total effort: two Cypher statements and two HTTP calls.
The one thing that never happens in this workflow is a connection from CoreModels to your database. There is no Bolt URI to configure and no service account to provision. You run the extraction; we read the artifacts you hand us.
Throughout, the API host is https://coremodels.example.com, $PROJECT_ID is the 32-character hex id of the CoreModels project that will govern the estate, and $TOKEN is your CoreModels login token. The vendor key is neo4j, and the connector declares all three capabilities: Import, Audit, and Generate.
What you need
- A Neo4j instance with the APOC library installed —
apoc.meta.schema()is the extraction workhorse. - A CoreModels project. Import requires the Admin role on it; audit needs only Viewer.
curlandjq.
Step 1 — Extract the two artifacts
Run these against the instance you want to govern, in Neo4j Browser or cypher-shell, and save the results:
CALL apoc.meta.schema() YIELD value RETURN value; // save the value as meta_schema.json
SHOW CONSTRAINTS; // export rows as constraints.json
| Artifact name | Content | Required |
|---|---|---|
meta_schema | the value returned by apoc.meta.schema() — one JSON object keyed by label and relationship-type name | yes |
constraints | the SHOW CONSTRAINTS rows as a JSON array | no, but recommended |
Our worked example graph produces a meta_schema.json like this:
{
"value": {
"Person": {
"type": "node",
"count": 1200,
"properties": {
"email": { "type": "STRING", "existence": false, "indexed": true, "unique": true },
"name": { "type": "STRING", "existence": true, "indexed": false, "unique": false },
"age": { "type": "INTEGER", "existence": false, "indexed": false, "unique": false },
"joined_at": { "type": "DATE_TIME", "existence": false, "indexed": false, "unique": false }
},
"relationships": {
"WORKS_FOR": { "direction": "out", "count": 900, "labels": ["Company"] },
"KNOWS": { "direction": "out", "count": 4000, "labels": ["Person"] },
"OWNS": { "direction": "out", "count": 40, "labels": ["Company", "Asset"] }
}
},
"Company": {
"type": "node",
"count": 200,
"properties": {
"name": { "type": "STRING", "existence": false, "indexed": true, "unique": false },
"founded": { "type": "INTEGER", "existence": false, "indexed": false, "unique": false }
},
"relationships": {
"WORKS_FOR": { "direction": "in", "count": 900, "labels": ["Person"] }
}
},
"Asset": {
"type": "node",
"count": 60,
"properties": {
"serial": { "type": "STRING", "existence": false, "indexed": false, "unique": false }
},
"relationships": {}
},
"WORKS_FOR": { "type": "relationship", "count": 900 },
"KNOWS": { "type": "relationship", "count": 4000 },
"OWNS": { "type": "relationship", "count": 40 }
}
}
And constraints.json:
[
{ "name": "person_email_unique", "type": "UNIQUENESS", "entityType": "NODE", "labelsOrTypes": ["Person"], "properties": ["email"] },
{ "name": "person_name_exists", "type": "NODE_PROPERTY_EXISTENCE", "entityType": "NODE", "labelsOrTypes": ["Person"], "properties": ["name"] },
{ "name": "company_name_key", "type": "NODE_KEY", "entityType": "NODE", "labelsOrTypes": ["Company"], "properties": ["name"] }
]
Two things to know before moving on. First, the parser is deliberately tolerant about the envelope: a bare object, the {"value": ...} wrapper that YIELD value produces, or a one-row array all parse to the same thing — save whatever your tooling gives you. Second, apoc.meta.schema() builds its picture by sampling the store, so its existence and unique flags are an observation rather than the database's authoritative word. That is exactly why the constraints artifact exists: SHOW CONSTRAINTS is authoritative, and its UNIQUENESS, NODE_PROPERTY_EXISTENCE, and NODE_KEY rows are merged into the fields on top of what APOC reported. In our example, that is what turns Company.name from "sampled as not unique" into a governed uniqueness fact.
Step 2 — Import the estate
Import is an Admin-role call on the interactive surface. Build the body with jq --rawfile so both JSON documents are embedded as strings without shell-quoting hazards:
jq -n --rawfile meta meta_schema.json --rawfile cons constraints.json \
'{artifacts: {meta_schema: $meta, constraints: $cons}}' > import-request.json
curl -sS -X POST \
"https://coremodels.example.com/graph/integrations/neo4j/import/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @import-request.json
For the graph above, the response is:
{ "success": true, "vendor": "neo4j", "projectName": "neo4j",
"datasetsAdded": 3, "datasetsSkippedExisting": 0, "fieldsAdded": 0,
"lineageEdgesAdded": 0, "lineageEdgesSkipped": 0, "nodesEnriched": 13,
"snapshotStored": true, "lossiness": [], "errors": [] }
Reading it counter by counter:
datasetsAdded: 3— the three node labels became governed Types. Relationship-type entries (WORKS_FOR,KNOWS,OWNSat the top level) duplicate what the node entries already carry, so they are counted as parser facts and skipped rather than governed twice.datasetsSkippedExisting: 0— import is additive. Labels already governed are left exactly as they are; nothing is mutated or overwritten. On a re-import this number goes up, and that is the system working as designed.fieldsAdded: 0— this counter is specifically fields added to already-governed labels, the additive re-import path. On a first import the properties arrive complete with their new Types through the schema writer, so it stays zero. Add a property toPersonlater, re-import, and it becomes1.nodesEnriched: 13— every governed node that received Neo4j metadata: 3 Types plus 10 Elements. The ten are Person's four properties and three outgoing relationships, Company's two properties, and Asset's one.lineageEdgesAdded: 0— expected, and not a gap.apoc.meta.schema()describes structure, not data flow; a typed relationship is schema meaning, and it is governed as a reference, not as a lineage edge.snapshotStored: true— the parsed estate snapshot was persisted. This is what makes artifact-free re-audits possible later.lossiness/errors— lossiness records what a successful import had to approximate; errors mean it could not proceed at all. Both empty here.
What landed in the graph: each label is a Type with materialization node and its sampled node count on metadata; each property is an Element carrying APOC's native type (STRING, INTEGER, DATE_TIME, …), with existence becoming a NotNull check and unique becoming a Unique check; and each outgoing typed relationship becomes a reference Element named after the relationship type, targeting the first target label. (:Person)-[:WORKS_FOR]->(:Company) is governed as a WORKS_FOR reference on Person pointing at Company — the graph-native foreign key. Incoming directions are not duplicated, which is why Company gets two Elements, not three.
Step 3 — Run the baseline audit
The audit compares fresh artifacts against the governed model. It is read-only and runs at Viewer role. recordHistory: true adds the run to the project's rolling drift trail — by default the verb records nothing at all:
jq -n --rawfile meta meta_schema.json --rawfile cons constraints.json \
'{artifacts: {meta_schema: $meta, constraints: $cons}, recordHistory: true}' > audit-request.json
curl -sS -o audit-response.json -X POST \
"https://coremodels.example.com/graph/integrations/neo4j/audit/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @audit-request.json
jq '{errorCount, warningCount, infoCount, codes, metrics}' audit-response.json
Because you just imported these artifacts, drift is zero — which makes this the ideal moment to learn the report's anatomy:
{
"errorCount": 0,
"warningCount": 1,
"infoCount": 1,
"codes": { "label-no-unique-id": 1, "polymorphic-relationship": 1 },
"metrics": {
"Datasets (estate)": "3",
"Datasets governed": "3 / 3",
"Fields governed": "10 / 10",
"Governed nodes with canonical mappings": "0 / 13 (0%)",
"Last import": "2026-08-04T09:12:44.1180000+00:00"
}
}
errorCount > 0 is the gate signal used by CI; warnings and infos inform without blocking. The metrics line worth noticing is the last one: zero of thirteen governed nodes carry a canonical mapping — a mapping to a standard other than the vendor itself. Right now the model knows these nodes as Neo4j objects and nothing more. Attaching canonical meaning is the work that turns an imported estate into a governed vocabulary, and this metric tracks it.
Step 4 — Read the findings
Findings arrive in three sections. Coverage reports what the estate has and the governed model does not (dataset-unmapped, field-unmapped). Drift reports disagreement between them (field-removed, field-type-drift, enum-constraint-removed, enum-narrowed, enum-widened, contract-drift, dataset-removed). Conformance carries the connector's own best-practice rules — Neo4j contributes two:
| Code | Severity | Meaning |
|---|---|---|
label-no-unique-id | Warning | the label has no uniqueness constraint on any property, so MERGE-based ingestion can silently create duplicates |
polymorphic-relationship | Info | the relationship targets multiple labels — only the first is governed as a reference |
Both fired on our example:
[
{ "section": "Conformance", "severity": "Warning", "code": "label-no-unique-id",
"subject": "Asset",
"message": "Node label has no uniqueness constraint — MERGE-based ingestion can silently create duplicates.",
"detail": null },
{ "section": "Conformance", "severity": "Info", "code": "polymorphic-relationship",
"subject": "Person.OWNS",
"message": "Relationship targets multiple labels — only the first is governed as a reference.",
"detail": "targets: Company,Asset" }
]
Person and Company escaped the first rule because the constraints artifact gave them uniqueness facts; Asset has none, and that is precisely the condition under which a nightly MERGE (a:Asset {serial: $s}) starts producing duplicates the day two loaders disagree about serial formatting. The Person.OWNS note is not a defect — polymorphic edges are legitimate modeling — but a reader of the governed model would otherwise believe the reference is single-target, so we say it every time.
The same report comes back pre-rendered for humans:
jq -r '.markdown' audit-response.json > audit-report.md
That file leads with a verdict line and the three counts, followed by a metrics table and collapsible per-section finding lists — paste-ready into a pull request or a review doc.
Where this goes next
You now have a governed model and a recorded baseline. Four verbs extend it: POST .../reaudit/$PROJECT_ID re-checks the stored snapshot against the current governed model whenever meaning changes, with no artifacts needed; GET .../history/$PROJECT_ID returns the rolling trail; GET .../badge/$PROJECT_ID serves an SVG status badge from the latest recorded run; and POST .../generate/$PROJECT_ID emits coremodels_constraints.cypher, an idempotent script that makes Neo4j itself enforce the governed shape — and once a steward governs Asset.serial as unique in CoreModels, regeneration includes the uniqueness constraint Asset is missing today.
For the condensed reference version of these commands, see the Neo4j quickstart in the CoreModels integration docs.