Every stalled Neo4j upgrade we get called into has the same shape. The database version bump is fine. The driver bump is fine. Then someone runs the integration suite and forty queries fail because a procedure that existed in the old deployment is not on the new one — usually something from APOC Extended, often on a managed instance where installing a plugin is not an option.
APOC is not the problem. Treating it as one undifferentiated dependency is. This tutorial is the audit we run at the start of an upgrade or migration engagement: find every apoc.* call you actually execute, sort it into buckets, and delete the calls that modern Cypher does natively.
The three buckets that matter
APOC Core ships and is versioned alongside the database, and is supported by Neo4j. It is the bucket you can keep depending on — as long as you install the build that matches your database version, because APOC is version-locked to the server, not backwards compatible across it.
APOC Extended is a separate, community-maintained artifact. It is not supported, it is not installed by default, and it is where the riskiest things live: triggers, custom procedures, apoc.load.* variants that reach out over the network, and assorted integrations. Code that depends on Extended is code that depends on a plugin somebody has to remember to install, with matching versions, on every environment forever.
What Aura allows is a third, narrower thing again: a curated subset, mostly of Core. If a move to Aura is anywhere on your roadmap — see our Aura and managed cloud notes — then any Extended call in your codebase is a migration blocker you may as well find now, while it is cheap.
The buckets shift between releases, so never audit from memory. Ask the server:
SHOW PROCEDURES YIELD name, description, isDeprecated
WHERE name STARTS WITH 'apoc.'
RETURN name, isDeprecated
ORDER BY isDeprecated DESC, name;
SHOW FUNCTIONS YIELD name, isDeprecated
WHERE name STARTS WITH 'apoc.' AND isDeprecated
RETURN name ORDER BY name;
Run that on your target version — a fresh container of the version you are upgrading to, not your current production box. Anything your code calls that is absent from that list is a hard break; anything flagged isDeprecated is a soft one with a deadline.
Step 1 — inventory what you actually call
Two sources, and you need both, because neither is complete on its own.
Static: grep the repositories. APOC calls hide in application code, in Cypher files, in migration scripts, in dashboards, in @cypher directives in GraphQL type definitions, and in ETL jobs.
grep -rhoiE 'apoc\.[a-z0-9_]+(\.[a-z0-9_]+)+' \
--include='*.cypher' --include='*.cql' --include='*.java' --include='*.py' \
--include='*.js' --include='*.ts' --include='*.graphql' --include='*.json' . \
| tr 'A-Z' 'a-z' | sort | uniq -c | sort -rn
Dynamic: read the query log. Static analysis misses procedures composed at runtime and finds plenty of dead code that no longer runs. If you have query logging on — and if you followed our observability setup you do — mine a representative window, ideally one that includes a month-end or batch-heavy day:
grep -ohiE 'apoc\.[a-z0-9_]+(\.[a-z0-9_]+)+' logs/query.log \
| tr 'A-Z' 'a-z' | sort | uniq -c | sort -rn
Put the two lists side by side in a spreadsheet with four columns: procedure, call sites, calls per day, bucket. Rows with zero runtime calls are usually deletions, not migrations, and deletions are the cheapest kind of fix.
Step 2 — the replacements worth making
Sorted by how often they pay off in real codebases.
Batched writes: apoc.periodic.iterate → transaction subqueries
This is the single highest-volume replacement we make. Subqueries IN TRANSACTIONS are native Cypher, need no plugin, and are visible to the planner:
-- was: CALL apoc.periodic.iterate(
-- 'MATCH (p:Person) WHERE p.email IS NOT NULL RETURN p',
-- 'SET p.emailLower = toLower(p.email)',
-- {batchSize: 10000, parallel: true})
MATCH (p:Person) WHERE p.email IS NOT NULL
CALL (p) {
SET p.emailLower = toLower(p.email)
} IN TRANSACTIONS OF 10000 ROWS;
The parallel: true equivalent is IN CONCURRENT TRANSACTIONS on Cypher 25, which comes with real lock-contention caveats — we covered those in detail in Bulk Writes Without the Deadlocks. Also note the error semantics differ: apoc.periodic.iterate swallows failed batches into a result row by default, while a transaction subquery fails loudly unless you add ON ERROR CONTINUE. Loud is usually what you wanted.
apoc.periodic.commit maps onto the same construct and should go at the same time.
Dynamic labels, types, and properties → Cypher 25 syntax
apoc.create.node, apoc.create.relationship, apoc.merge.node and friends existed because Cypher could not take a label from a variable. Cypher 25 can:
-- was: CALL apoc.merge.node(['Product'], {sku: row.sku}, {}, {}) YIELD node
UNWIND $rows AS row
MERGE (n:$(row.label) {sku: row.sku})
SET n += row.props;
-- dynamic relationship type
MATCH (a:Entity {id: $from}), (b:Entity {id: $to})
MERGE (a)-[r:$($relType)]->(b);
-- dynamic property keys have worked for a while
SET n[$propName] = $value;
Check the syntax against the release notes for your exact target version before you rewrite in bulk — this area moved across several releases, and $() is the Cypher 25 form.
Similarity and vectors → native functions and the vector index
If anything in your codebase still computes cosine similarity in APOC or, worse, in plain Cypher arithmetic, replace it with the native vector.similarity.cosine() / vector.similarity.euclidean() functions and a real vector index. Our vector search tutorial walks the index and the native vector type; the short version is that a hand-rolled similarity over a few hundred thousand nodes is the slowest query in most GraphRAG prototypes we review.
Triggers → change data capture
apoc.trigger.* is Extended, unavailable on Aura, and puts application logic inside the database where nobody can test it. CDC is the supported path, it works on Aura, and it moves the logic somewhere you can version and unit-test. Streaming Graph Changes: Neo4j CDC, Cursors, and Kafka is the full pattern. If the trigger merely enforces an invariant, a constraint may be the real answer:
CREATE CONSTRAINT person_email_unique IF NOT EXISTS
FOR (p:Person) REQUIRE p.email IS UNIQUE;
CREATE CONSTRAINT order_total_exists IF NOT EXISTS
FOR (o:Order) REQUIRE o.total IS NOT NULL;
Schema introspection → SHOW commands and db.*
apoc.meta.schema and apoc.meta.data are expensive on large graphs because they sample the store. For tooling and CI checks, the built-ins are cheaper and stable:
SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties;
SHOW INDEXES YIELD name, type, state, populationPercent;
CALL db.labels();
CALL db.relationshipTypes();
CALL db.schema.visualization();
Wire SHOW CONSTRAINTS and SHOW INDEXES into the assertion step of your migration tests so a missing index fails the build instead of page-caching production into the ground.
Dates, collections, strings, maps → mostly already native
A large share of apoc.date.*, apoc.coll.*, apoc.text.* and apoc.map.* calls in older codebases have had native equivalents for years and survive only by inertia:
RETURN datetime({epochMillis: $ms}) AS ts,
datetime($iso) + duration({days: 30}) AS renewal,
toString(date()) AS today,
reduce(s = 0, x IN $nums | s + x) AS total,
[x IN $nums WHERE x > 10] AS filtered,
split(trim($raw), ',') AS parts,
apoc.map.clean IS NULL AS remove_me;
These are the safest rewrites in the list and good work to hand a junior engineer, because the test for correctness is a one-line comparison against the old expression.
apoc.custom.* and apoc.cypher.runFirstColumn → out of the database
Custom procedures registered at runtime are Extended, invisible to source control, and a recurring cause of "it works on staging". Move that logic into the application layer, a named query in your repository, or — if it genuinely must run server-side — a proper compiled user-defined procedure that ships as a reviewed artifact. apoc.cypher.runFirstColumn shows up mostly in older generated GraphQL layers; current Neo4j GraphQL versions no longer need it, so the fix is usually a library upgrade rather than a query rewrite.
Step 3 — prove the rewrite before you ship it
For each replaced query, do three things, in this order.
- Pin behaviour with a test. Spin the target version in Testcontainers, seed a fixture, run old and new against it, and diff the results — including ordering and null handling, which is where APOC and native Cypher most often disagree.
- Compare plans, not just results.
PROFILEboth forms on a realistic data volume. A rewrite that returns identical rows while adding anAllNodesScanis not an improvement; reading a PROFILE plan tells you which one you got. - Watch the first production run. Batched rewrites change transaction sizes, and transaction size changes heap pressure. Keep an eye on the memory and page-cache metrics for the first full cycle.
Step 4 — stop the drift
Audits decay. Two cheap guards keep this from being annual work:
- A CI job that fails the build when a new
apoc.string appears in a diff without an entry in anAPOC-ALLOWLIST.mdfile. Not a ban — a decision record. - A startup check in staging that asserts every allowlisted procedure resolves in
SHOW PROCEDURES, so a missing or mismatched plugin fails at deploy time rather than at 2 a.m. during a batch job.
# CI guard: flag new apoc.* usage that is not on the allowlist
git diff --unified=0 origin/main -- . \
| grep '^+' | grep -ohiE 'apoc\.[a-z0-9_]+(\.[a-z0-9_]+)+' | sort -u \
| while read -r p; do
grep -qi "$p" APOC-ALLOWLIST.md || { echo "unapproved APOC call: $p"; exit 1; }
done
What good looks like
The goal is not zero APOC. Core does real work that Cypher still does not, and rewriting a working, supported call to prove a point is waste. The goal is a short, deliberate, documented list of APOC procedures you depend on, all of them Core, all of them available on the platforms you plan to run on, with everything else either replaced by native Cypher or deleted. Teams that get there upgrade in an afternoon instead of a quarter.
If your upgrade is already stuck behind a plugin nobody wants to own, that is a well-worn problem — our Neo4j consultants do this audit as a fixed-scope piece of work. Get in touch with your current version, your target version, and roughly how big the codebase is, and we will tell you what the audit is likely to turn up.