Avoiding Duplicated Nodes in Neo4j with MERGE

When loading data into a Neo4j knowledge graph, CREATE blindly inserts a new node every time, leaving duplicated, disconnected entities. This post explains why MERGE is the safer choice because its match-first-then-create behaviour prevents duplicated nodes.

graph-rag
neo4j
LLM
Author

Mei-Chin Pang

Published

August 18, 2026

Why MERGE is Needed Compared to CREATE

In the import queries we wrote earlier, we used CREATE, which simply creates a node or relationship exactly as instructed, every time the query runs. That is fine for a single, clean insert, but it becomes a problem the moment the same entity is described more than once. Every track in the CSV has a unique track id, and if a track already exists in the graph we do not want to create it again. CREATE has no notion of “already there”: it blindly inserts a new node, so the same track ends up stored as several disconnected copies.

We saw exactly this failure in the previous post, Understanding the Fundamentals of Graph RAG. After importing the tracks file and the playlists file separately with CREATE, viewing the whole database showed two chains that should have been joined:

  • from the playlists file: (:User)-[:OWNS]->(:Playlist)-[:HAS_TRACK]->(:Track)
  • from the tracks file: (:Album)-[:HAS_TRACK]->(:Track)-[:ARTIST]->(:Artist)

The Track in both chains is the same song and should be a single shared node linking the two subgraphs. Because we used CREATE, Neo4j instead produced two separate Track nodes. Clicking each one confirmed the problem: their internal <id> values differed (they are genuinely distinct nodes), yet their business id property was identical, id: "1FTSo4v6BOZH9QxKc3MbVM", which is the track_id for that song in both CSV files. That shared track_id is the key we can use to recognise the two rows as the same track and collapse them into one node.

What MERGE does differently

MERGE is a combination of MATCH and CREATE. It first tries to find the pattern you are looking for in its entirety, and if it already exists, nothing is created. Only if the pattern cannot be matched is the whole pattern created. This “match first, create only if missing” behaviour is precisely what prevents the duplicate Track nodes we saw above.

Because we are looking to avoid duplicate track nodes, MERGE needs a reliable key to MATCH a track on first. The id of the track, album, or artist is unique, so it can be used to determine whether the node already exists in the graph:

MERGE (track:Track {id: row.track_id})
SET track.uri = row.track_uri,
    track.name = row.track_name

Written this way, loading the playlists file matches the existing Track node created by the tracks file instead of making a second one, so both patterns attach to a single shared node and the graph becomes properly connected.

Guarding against parallel duplicates with a constraint

You should also create a uniqueness constraint on the key property. Besides speeding up the MERGE match with an index, it guarantees duplicates cannot be created even when data is ingested into the graph in parallel:

CREATE CONSTRAINT track_id_unique IF NOT EXISTS
FOR (t:Track) REQUIRE t.id IS UNIQUE;

For our current use case, which is the ease of loading data into Neo4j while preventing duplicates, we simply rely on the unique identifiers already available in the dataset (the track, album, and artist ids) as the MERGE keys.

Best Practices for Using MERGE

Working with MERGE needs some care, and the best advice is to keep it simple. Always remember the core rule: MERGE attempts to match the entire pattern, and when it cannot, it creates the entire pattern. Getting comfortable with this all-or-nothing behaviour is the key to avoiding accidental duplicates.

A single node is a pattern too

Consider merging one artist node:

MERGE (n:Artist {name:"Wham!", origin:"UK"})

For this MERGE to match an existing node (and therefore not create another one), your graph must already contain:

  • a node labeled Artist (other labels may exist and do not matter), and
  • a property name with value "Wham!" and a property origin with value "UK" (other properties may exist and do not matter).

If the artist node in your graph has the property name with value "Wham!" but no origin property, the pattern does not match, so a new Artist node with both properties is created. You now have two Wham! artist nodes (assuming a uniqueness constraint did not fail first). The lesson: every property you put inside a MERGE becomes part of the key it must match on.

Patterns with relationships match as a whole

The same all-or-nothing rule applies to patterns that contain relationships. Suppose the artist Wham! already exists and you want to link a track to it:

MERGE (t:Track {name:"Last Christmas"})-[:ARTIST]->(a:Artist {name:"Wham!"})

It is tempting to assume this will reuse the existing Wham! node and only create the track and the ARTIST relationship. It will not. Because the track "Last Christmas" is not yet in the graph, the entire pattern fails to match, so MERGE creates the entire pattern, including a second Wham! node.

Keep it simple: merge nodes first, then relationships

The reliable approach is to MERGE each node individually on its key, leaving off the other properties, and then use those matched nodes to MERGE the relationship between them:

MERGE (t:Track {name:"Last Christmas"})
MERGE (a:Artist {name:"Wham!"})
MERGE (t)-[:ARTIST]->(a)

Here each node is matched (or created) on its own key first, so the existing Wham! node is reused, and only then is the ARTIST relationship merged between the two known nodes. Breaking a compound pattern into separate single-node merges followed by a relationship merge is the safest way to keep your graph free of duplicates.

Importing One Track with MERGE

Applying these practices to our dataset, we rewrite the single-track import to use MERGE on each node’s unique id before merging the relationships between them:

//Import one track with MERGE
LOAD CSV WITH HEADERS FROM "file:///sample_tracks.csv" AS row
WITH row LIMIT 1

MERGE (track:Track {id: row.track_id})
SET track.uri = row.track_uri,
track.name = row.track_name

MERGE (album:Album {id: row.album_id})
SET album.uri = row.album_uri,
album.name = row.album_name

MERGE (artist:Artist {id: row.artist_id})
SET artist.uri = row.artist_uri,
artist.name = row.artist_name

MERGE (album)-[:HAS_TRACK]->(track)
MERGE (track)-[:ARTIST]->(artist);

Importing One Playlist with MERGE

The playlists file follows the same pattern. Each Playlist, User, and Track node is merged on its own id first, and then the OWNS and HAS_TRACK relationships are merged between the already-matched nodes. Because the Track is merged on the same track_id used by the tracks import, it reuses the existing node instead of creating a duplicate:

//Import one playlist with MERGE
LOAD CSV WITH HEADERS FROM "file:///sample_playlists.csv" AS row
WITH row LIMIT 1

MERGE (playlist:Playlist {id: row.id})
SET playlist.name = row.name

MERGE (user:User {id: row.user_id})
MERGE (track:Track {id: row.track_id})

MERGE (user)-[:OWNS]->(playlist)
MERGE (playlist)-[:HAS_TRACK {position: row.track_index}]->(track);

Check Duplicated Nodes Are Gone

After importing both files with MERGE, we can verify that the playlist and the album/artist chains now share a single Track node instead of two disconnected copies. The following query walks from a user all the way through to the track’s album or artist:

MATCH path=(user)-[:OWNS]->(p:Playlist)
-[:HAS_TRACK]->(track:Track)--(albumOrArtist)
RETURN path;
Figure 1: After importing with MERGE, the playlist chain and the album/artist chain meet at one shared Track node (“Song 2”), whose id is 1FTSo4v6BOZH9QxKc3MbVM.

Reading the query line by line

  • MATCH path=(user)-[:OWNS]->(p:Playlist) starts the pattern at a user node that OWNS a Playlist, binding the playlist to p.
  • -[:HAS_TRACK]->(track:Track) follows the playlist’s HAS_TRACK relationship to the Track it contains, binding it to track.
  • --(albumOrArtist) traverses one more relationship from that track in any direction and of any type (the bare -- has no arrow and no [:TYPE]), matching either the Album linked by HAS_TRACK or the Artist linked by ARTIST. The neighbouring node is bound to albumOrArtist.
  • path=(...) captures the whole matched chain, and RETURN path renders it as a connected subgraph in the Neo4j Browser.

Why the merged track matters

In Figure 1 the central Track node (“Song 2 - 2012 Remastered Version”) is reached from both sides at once: the bleapkin user’s playlist Pixies — Wher... points into it via HAS_TRACK, while the album Blur [Special Edition] points into it via HAS_TRACK and the track points out to the artist Blur via ARTIST. The node details panel shows its id, 1FTSo4v6BOZH9QxKc3MbVM, exactly the track_id shared by both CSV files.

This is the payoff of using MERGE: earlier, with CREATE, that same track_id produced two separate Track nodes and left the playlist and album/artist subgraphs disconnected. Because both imports now MERGE on {id: row.track_id}, the second import matched the track created by the first instead of duplicating it, so a single shared node joins the two subgraphs into one connected graph.

Importing All Playlists with MERGE

Once the single-row pattern is verified, we remove the WITH row LIMIT 1 clause so the same MERGE logic runs over every row in the file, importing all playlists at once while still reusing existing Track nodes rather than duplicating them. We first import all tracks, merging each Track, Album, and Artist on its unique id and then merging the relationships between them:

//merge-all-sample-tracks.cypher
LOAD CSV WITH HEADERS FROM "file:///sample_tracks.csv" AS row
MERGE (track:Track {id: row.track_id})
SET track.uri = row.track_uri,
track.name = row.track_name

MERGE (album:Album {id: row.album_id})
SET album.uri = row.album_uri,
album.name = row.album_name

MERGE (artist:Artist {id: row.artist_id})
SET artist.uri = row.artist_uri,
artist.name = row.artist_name

MERGE (album)-[:HAS_TRACK]->(track)
MERGE (track)-[:ARTIST]->(artist);

We then import all playlists, reusing the Track nodes created above:

//merge-all-sample-playlists.cypher
LOAD CSV WITH HEADERS FROM "file:///sample_playlists.csv" AS row
MERGE (playlist:Playlist {id: row.id})
SET playlist.name = row.name

MERGE (user:User {id: row.user_id})
MERGE (track:Track {id: row.track_id})
MERGE (user)-[:OWNS]->(playlist)

MERGE (playlist)-[:HAS_TRACK {position: row.track_index}]->(track);

Examples of Querying the Graph

(1) Counting the Playlist nodes in the graph

MATCH (n:Playlist)
RETURN count(n) AS playlistCount;

Here MATCH (n:Playlist) finds every node labeled Playlist in the graph and binds each one to n, while count(n) tallies how many such nodes were matched and AS playlistCount names the returned column. As shown in Figure 2, the result is 40, which matches the number of playlists in the source file and confirms they were all imported without duplication.

Figure 2: Counting the Playlist nodes returns 40, confirming all playlists in the file were imported.

(2) Finding tracks that appear in more than one playlist

We can also check whether any track appears in more than one playlist, which is a good sign that MERGE reused a single shared Track node rather than duplicating it:

MATCH (t:Track)<-[:HAS_TRACK]-(p:Playlist)
WITH t AS track, count(p) AS playlistCount
WHERE playlistCount > 1
RETURN track.name as trackName, playlistCount;

Reading this query in order: MATCH (t:Track)<-[:HAS_TRACK]-(p:Playlist) finds every playlist p that contains a track t; WITH t AS track, count(p) AS playlistCount groups by each track and counts how many playlists reference it; WHERE playlistCount > 1 keeps only tracks shared by more than one playlist; and RETURN track.name as trackName, playlistCount reports those tracks with their playlist counts. As shown in Figure 3, the track “Where Is My Mind?” appears in 2 playlists, confirming that both playlists point to the same single Track node.

Figure 3: Filtering for tracks shared by more than one playlist returns “Where Is My Mind?” with a playlistCount of 2, showing the track node is reused across playlists.

(3) Finding the artists featured in the most playlists

We can also rank artists by how many distinct playlists feature their tracks, which shows how the merged graph links artists to playlists through their shared Track nodes:

MATCH (a:Artist)<-[:ARTIST]-(track)<-[:HAS_TRACK]-(p:Playlist)
RETURN a.name AS artistName, count(distinct p) AS playlistCount
ORDER BY playlistCount DESC
LIMIT 5;

Reading this query in order: MATCH (a:Artist)<-[:ARTIST]-(track)<-[:HAS_TRACK]-(p:Playlist) finds every playlist p that contains a track whose artist is a; count(distinct p) AS playlistCount counts the distinct playlists per artist, so a playlist is only counted once even if it holds several of that artist’s tracks; RETURN a.name AS artistName, ... reports each artist with that count; ORDER BY playlistCount DESC sorts the busiest artists first; and LIMIT 5 keeps only the top five. As shown in Figure 4, this returns the five artists whose tracks appear across the most playlists.

Figure 4: Ranking artists by the number of distinct playlists that feature their tracks returns the top five most-featured artists.

(4) Inspecting the data type of the HAS_TRACK position

Before relying on the position stored on each HAS_TRACK relationship, it is worth inspecting a few rows to see how the value was loaded:

MATCH (a:Artist)<-[:ARTIST]-(t:Track)<-[r:HAS_TRACK]-(p:Playlist)
RETURN a.name AS artist, t.name as track, r.position as position
LIMIT 5;

Reading this query in order: MATCH (a:Artist)<-[:ARTIST]-(t:Track)<-[r:HAS_TRACK]-(p:Playlist) finds each playlist p that has a track t whose artist is a, binding the HAS_TRACK relationship to r; RETURN a.name AS artist, t.name as track, r.position as position reports the artist, the track, and the track’s position within the playlist; and LIMIT 5 returns only the first five rows. As shown in Figure 5, the position column is displayed as a string (for example "0", "1") rather than a number.

Figure 5: Inspecting the HAS_TRACK position shows it is stored as a string, with the values quoted rather than shown as plain numbers.

The reason position is a string is that LOAD CSV reads every column as text, so row.track_index was stored verbatim as a string when the relationships were merged, and Neo4j never coerced it to an integer.

This matters for any query that compares position against a number. Take the following query, which tries to find the artist whose track sits in the last position of a playlist by comparing position to the playlist’s track count:

MATCH (a:Artist)<-[:ARTIST]-(t:Track)<-[r:HAS_TRACK]-(p:Playlist)
WHERE r.position = COUNT { (p)-[:HAS_TRACK]->() }
RETURN a.name AS artist, count(*) AS numberOfTracks
ORDER BY numberOfTracks DESC
LIMIT 1;

Here COUNT { (p)-[:HAS_TRACK]->() } returns an integer, but r.position is a string, so the comparison r.position = COUNT { ... } compares a string to a number. In Cypher those two types are never equal, so the WHERE clause filters out every row and the query returns nothing, even though the positions look correct. To make the comparison work, the string must be converted to an integer first, for example with toInteger(r.position) = COUNT { (p)-[:HAS_TRACK]->() }.

Rather than converting on every read, we can fix the data at the source by changing the type of the values stored on the position property of the HAS_TRACK relationships to be an integer:

MATCH (p:Playlist)-[r:HAS_TRACK]->()
SET r.position = toInteger(r.position);

This matches every HAS_TRACK relationship and overwrites its position with the integer form of the same value, as shown in Figure 6.

Figure 6: Updating every HAS_TRACK relationship so its position property is stored as an integer instead of a string.

With position now stored as an integer, the original query returns results because the comparison is between two numbers:

MATCH (a:Artist)<-[:ARTIST]-(t:Track)<-[r:HAS_TRACK]-(p:Playlist)
WHERE r.position = COUNT { (p)-[:HAS_TRACK]->() }
RETURN a.name AS artist, count(*) AS numberOfTracks
ORDER BY numberOfTracks DESC
LIMIT 1;

As shown in Figure 7, the query now matches rows and returns the artist with the most tracks positioned last in a playlist.

Figure 7: After converting position to an integer, the comparison succeeds and the query returns the top artist with a numberOfTracks count.

Viewing the Fully Imported Graph

With every playlist and track imported, we can view the entire database to see the final result. The following query returns all nodes, keeping even those without an outgoing relationship:

MATCH (n) OPTIONAL MATCH (n)-[r]->(o) RETURN *
Figure 8: The complete graph after importing all playlists and tracks with MERGE, showing 195 records of connected User, Playlist, Track, Album, and Artist nodes.

Because both imports merged tracks on the shared track_id, the playlists and the album/artist data are woven into a single connected graph rather than the two duplicated, disconnected subgraphs we saw with CREATE.

Adding Constraints in Neo4j Community Edition

With the data imported, a natural next step is to add constraints that keep each node’s id unique and present. The NODE KEY constraint is the ideal fit because it enforces both at once: the id property must exist and be unique. We might try to create one for every label:

//008-index-creation.cypher
// The NODE KEY constraint ensures the id property is present AND unique
CREATE CONSTRAINT playlist_id FOR (n:Playlist) REQUIRE n.id IS NODE KEY;
CREATE CONSTRAINT user_id FOR (n:User) REQUIRE n.id IS NODE KEY;
CREATE CONSTRAINT track_id FOR (n:Track) REQUIRE n.id IS NODE KEY;
CREATE CONSTRAINT album_id FOR (n:Album) REQUIRE n.id IS NODE KEY;
CREATE CONSTRAINT artist_id FOR (n:Artist) REQUIRE n.id IS NODE KEY;

On Neo4j Community Edition this fails with:

Neo.DatabaseError.Schema.ConstraintCreationFailed
Unable to create Constraint( type='NODE KEY', schema=(:Playlist {id}) ):
Node Key constraint requires Neo4j Enterprise Edition.

Both NODE KEY and property-existence constraints are Enterprise-only features. The only constraint type available in Community Edition is the uniqueness constraint, which enforces the unique half of a NODE KEY but not the existence half.

Using uniqueness constraints instead

We replace each IS NODE KEY with IS UNIQUE, which Community Edition supports for every label:

//008-index-creation.cypher
// Community Edition: uniqueness constraints (unique only, not existence)
CREATE CONSTRAINT playlist_id IF NOT EXISTS
FOR (n:Playlist) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT user_id IF NOT EXISTS
FOR (n:User) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT track_id IF NOT EXISTS
FOR (n:Track) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT album_id IF NOT EXISTS
FOR (n:Album) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT artist_id IF NOT EXISTS
FOR (n:Artist) REQUIRE n.id IS UNIQUE;

A few things worth noting:

  • A uniqueness constraint automatically creates a backing range index on id, so our MERGE (n {id: ...}) lookups stay fast without a separate CREATE INDEX.

  • IF NOT EXISTS makes the script safe to run more than once.

  • Community Edition cannot enforce the existence half, but our imports already set id on every MERGE, so the key is always present. To double-check, we can look for any node missing the key:

    MATCH (n:Track) WHERE n.id IS NULL RETURN count(n);

Why constraints speed up importing a larger CSV

With the uniqueness constraints in place, we can import a much larger tracks file, sample_tracks_medium.csv, using exactly the same MERGE logic as before:

//import-larger-csv-file-tracks-medium.cypher
LOAD CSV WITH HEADERS FROM "file:///medium/sample_tracks_medium.csv" AS row

MERGE (track:Track {id: row.track_id})
SET track.uri = row.track_uri, track.name = row.track_name

MERGE (album:Album {id: row.album_id})
SET album.uri = row.album_uri, album.name = row.album_name

MERGE (artist:Artist {id: row.artist_id})
SET artist.uri = row.artist_uri, artist.name = row.artist_name

MERGE (album)-[:HAS_TRACK]->(track)
MERGE (track)-[:ARTIST]->(artist);

Every MERGE (track:Track {id: row.track_id}) has to first match whether a Track with that id already exists before deciding to create it. Without an index, Neo4j has no shortcut for that lookup: it must scan every existing Track node and compare its id, which is an O(n) operation. As the graph grows, each successive row scans a larger set of nodes, so the total cost of importing n rows grows roughly as O(n^2). For the tiny sample file this is unnoticeable, but for sample_tracks_medium.csv, with many more rows, that quadratic behaviour quickly makes the import slow.

Creating the uniqueness constraint first fixes this. As noted above, a uniqueness constraint automatically builds a backing range index on the id property. With that index, each MERGE lookup becomes an indexed seek (roughly O(\log n) instead of a full scan) so importing n rows drops from about O(n^2) to about O(n \log n). The larger the CSV, the bigger this saving, which is why creating the constraints (and their indexes) before the bulk import is the key to fast ingestion. The constraint also guarantees that no duplicate Track, Album, or Artist slips in while the larger file is loading.

We then import the matching larger playlists file. This time we fix the position data type during ingestion rather than in a separate pass:

//merge-large-playlists-and-cast-data-type-during-ingestion.cypher
LOAD CSV WITH HEADERS FROM "file:///medium/sample_playlists_medium.csv" AS row

MERGE (playlist:Playlist {id: row.id})
SET playlist.name = row.name

MERGE (user:User {id: row.user_id})
MERGE (track:Track {id: row.track_id})

MERGE (user)-[:OWNS]->(playlist)
MERGE (playlist)-[:HAS_TRACK {position: toInteger(row.track_index)}]->(track)

Recall from the smaller import that LOAD CSV reads every column as text, so row.track_index arrives as a string like "0" or "1". Earlier we had to run a second query (SET r.position = toInteger(r.position)) to repair the type after the fact. Here we wrap the value in toInteger(row.track_index) inside the MERGE, so the conversion happens as each relationship is created and the position is stored as an integer from the outset.

Doing the cast inside the MERGE also matters for correctness, not just convenience. MERGE matches on the exact pattern, including relationship properties, so MERGE (playlist)-[:HAS_TRACK {position: toInteger(row.track_index)}]->(track) looks for a HAS_TRACK relationship whose position is the integer value. Had we merged on the raw string row.track_index, re-running the import (or running it alongside the earlier integer-typed data) could fail to match the existing relationship and create a duplicate with a differently typed position. Converting the value before it is used as part of the merge key keeps the type consistent and the relationship de-duplicated.

Tip

As a rule of thumb, cast CSV columns to their intended types as they are loaded, so every node and relationship stores the right type the first time it is written.

After importing the larger tracks and playlists files, viewing the whole database with MATCH (n) OPTIONAL MATCH (n)-[r]->(o) RETURN * shows a much denser graph than the small sample, with many more Track, Album, Artist, Playlist, and User nodes and their relationships:

Figure 9: The full graph after importing the larger dataset, with many more connected nodes than the small sample.

References

  1. Misquitta, L. and Willemsen, C. (2025) Neo4j: The Definitive Guide: Hands-On Recipes for Production Ready Graph Implementations. O’Reilly Media.

  2. Bratanic, T. and Hane, O., 2025. Essential GraphRAG: Knowledge Graph-Enhanced RAG. Simon and Schuster.