The previous posts in this series used a small music/playlist graph and the built-in Movie graph to learn Cypher. From here on we switch to a larger and more interesting graph, an airport dataset, because it is the kind of data the Graph Data Science (GDS) algorithms were designed for: airports are nodes, the places they belong to are nodes, and questions such as which places are most central in the network? or which nodes form tightly connected communities? only make sense on a graph of this shape.
Starting the flights DBMS
We do not need a new setup for this. The Docker Compose file introduced in Understanding the Fundamentals of Graph RAG, under A worked example: one DBMS per dataset, already defines a second service called flights next to the movies one. That service is the one we use throughout this post, and it was deliberately configured for exactly this work:
| Setting | Value for flights |
|---|---|
| Browser | http://localhost:7475 |
| Bolt | bolt://localhost:7688 |
| User | neo4j |
| Password | flightspassword |
| Plugins | GDS + APOC |
| Heap | 4G |
| Import mount | ~/neo4j-volumes/flights/import |
The GDS plugin is only installed on this instance, which is why the graph algorithms in the rest of this post run here and not on the movies DBMS.
From the folder that holds docker-compose.yml, bring up just this one service:
docker compose up -d flights # start only the flight DBMS
docker compose ps # check that it is running and healthy
docker compose logs -f flights # follow the startup log if it is notThen open http://localhost:7475 and log in as neo4j / flightspassword. When you are finished:
docker compose stop flights # stop it, keep the dataThe flights service mounts ${HOME}/neo4j-volumes/flights/import rather than the project’s ./import folder. Keeping the database volumes outside the Quarto project avoids the PermissionDenied ... readdir error that quarto render otherwise hits when it scans the root-owned folders Neo4j creates.
Step 1: Load the CSV dataset
Making the CSV visible to Neo4j
LOAD CSV with a file:/// URL does not read from your host filesystem, it reads from Neo4j’s import directory inside the container. For the flights service that directory is mapped to ~/neo4j-volumes/flights/import, so the airport file has to be copied there first:
mkdir -p ~/neo4j-volumes/flights/import
cp posts/14_graph_rag/import/airport-node-list.csv \
~/neo4j-volumes/flights/import/Neo4j picks the file up immediately; there is no need to restart the container. If the folder is owned by root because Neo4j created it first, prefix the copy with sudo and make sure the file stays readable (sudo chmod a+r ~/neo4j-volumes/flights/import/airport-node-list.csv).
Inspecting the file before importing
The airport file has a header row, so we read it with LOAD CSV WITH HEADERS, which turns every line into a map keyed by the column names. Before creating a single node it is worth simply looking at the data. Returning the raw row for the first few lines shows the column names, the values, and, importantly, how Neo4j has typed them:
LOAD CSV WITH HEADERS FROM 'file:///airport-node-list.csv' AS row
RETURN row
LIMIT 5;
Two things are worth reading carefully in the result:
- The columns. Each airport carries an
id, theiataandicaocodes,city, a longerdescr,region,runways,longest,altitude,country,continent, and itslat/loncoordinates. - The types.
LOAD CSVreturns every field as a string, quotes in the file or not. That meansrunways,longest,altitude,lat, andlonarrive as text such as"5"and"33.6366996765137", and will have to be converted withtoInteger()andtoFloat()when we create the nodes. Skipping that conversion is the single most common mistake in aLOAD CSVimport: the data loads without any error, and only later does sorting by altitude or computing distances start returning nonsense.
The LIMIT 5 keeps this a cheap preview. To confirm that the whole file is readable and to see how many airports we are about to import, count the rows instead:
LOAD CSV WITH HEADERS FROM 'file:///airport-node-list.csv' AS row
RETURN count(row) AS airports;
The file holds 3503 airports.
Step 2: Adapting the Movie import to the airport data
In the Movie post the graph was written out by hand: every MERGE named a specific film or person. Here the same ideas apply, but the values come from the CSV instead of from the query text. The translation is mechanical:
| Movie graph | Airport graph |
|---|---|
MERGE (:Movie {title:'The Matrix'}) |
MERGE (:Airport {iata: row.iata}) |
MERGE (:Person {name:'Keanu Reeves'}) |
MERGE (:City {name: row.city, ...}) |
ON CREATE SET m.released=1999 |
ON CREATE SET a.altitude = toInteger(row.altitude) |
| One block per film | One pass over the CSV, one block per row |
ACTED_IN, DIRECTED, PRODUCED |
IN_CITY, IN_REGION, IN_COUNTRY, ON_CONTINENT |
airport-node-list.csv is exactly what its name says: a list of nodes. Every row describes one airport and nothing else, so the file on its own would make a table rather than a graph. What it does contain is geography, city, region, country, and continent, and each of those repeats across many rows. Turning those repeated strings into their own nodes is what gives us relationships to traverse and, later, to run algorithms over.
Adding uniqueness constraints
Exactly as in the Movie post, we add the constraints before importing. Besides preventing duplicates, each constraint creates an index that makes the MERGE statements below much faster, and with 3503 rows each doing five MERGEs, that difference is no longer academic:
CREATE CONSTRAINT airport_iata IF NOT EXISTS
FOR (a:Airport) REQUIRE a.iata IS UNIQUE;
CREATE CONSTRAINT city_name_country IF NOT EXISTS
FOR (c:City) REQUIRE (c.name, c.country) IS UNIQUE;
CREATE CONSTRAINT region_code IF NOT EXISTS
FOR (r:Region) REQUIRE r.code IS UNIQUE;
CREATE CONSTRAINT country_code IF NOT EXISTS
FOR (c:Country) REQUIRE c.code IS UNIQUE;
CREATE CONSTRAINT continent_code IF NOT EXISTS
FOR (c:Continent) REQUIRE c.code IS UNIQUE;
The choice of key is the one real decision here:
Airport.iata. The three-letter IATA code is the natural business key, and a quick check of the file confirms it is present and unique on every row. The numericidcolumn would work too, butiatais what people actually recognise and what other flight datasets join on.Cityneeds two properties. City names are not globally unique, so a single-property constraint onnamewould refuse to import the second Springfield or the second San José. A composite uniqueness constraint on(name, country)is the correct key, and it is available in Community Edition (onlyIS NODE KEYis Enterprise-only).Region,Country,Continent. These are already codes in the file (US-GA,US,NA), so the code itself is the key.
Importing the airports and their geography
The script below builds the whole graph in one pass. Like the Movie script it uses MERGE throughout, so it is idempotent: running it twice does not create duplicate nodes or relationships. Each row introduces one airport, the places it belongs to, and the relationships that connect them:
LOAD CSV WITH HEADERS FROM 'file:///airport-node-list.csv' AS row
MERGE (a:Airport {iata: row.iata})
ON CREATE SET a.id = toInteger(row.id),
a.icao = row.icao,
a.descr = row.descr,
a.runways = toInteger(row.runways),
a.longest = toInteger(row.longest),
a.altitude = toInteger(row.altitude),
a.location = point({
latitude: toFloat(row.lat),
longitude: toFloat(row.lon)})
MERGE (c:City {name: row.city, country: row.country})
MERGE (r:Region {code: row.region})
MERGE (co:Country {code: row.country})
MERGE (con:Continent {code: row.continent})
MERGE (a)-[:IN_CITY]->(c)
MERGE (a)-[:IN_REGION]->(r)
MERGE (r)-[:IN_COUNTRY]->(co)
MERGE (co)-[:ON_CONTINENT]->(con);
Figure 1 shows what a successful run looks like. The sidebar is the quickest sanity check: the labels Airport, City, Continent, Country and Region are all present, the relationship types are exactly the four the script creates, and the property keys include location, which confirms the spatial point was stored rather than a pair of strings. The 8,677 nodes are the 3,503 airports plus the roughly 5,000 distinct cities, regions, countries and continents they resolve to, which is precisely the deduplication that MERGE is doing on our behalf.
The parts worth pausing on:
- Type conversion. This is where the observation from Step 1 is paid off.
toInteger()andtoFloat()turn the CSV’s strings into real numbers, soORDER BY a.altitudesorts numerically instead of alphabetically. point(). Rather than storinglatandlonas two loose floats, we store a single spatialpoint. Neo4j can then compute great-circle distances withpoint.distance(a.location, b.location), which turns “how far apart are these two airports?” into a one-line query.ON CREATE SETversusSET. As in the Movie script,ON CREATE SETwrites the properties only when the node is genuinely new. Re-running the import therefore leaves existing airports untouched. Use plainSETinstead if you want a re-run to refresh the properties from an updated file.MERGEon the relationships.MERGE (a)-[:IN_CITY]->(c)looks for the whole pattern first, so the second airport in Atlanta reuses the existingCitynode instead of creating a second one. This is precisely the duplicate-node problem we ran into withCREATEin theMERGEpost.- Direction. The relationships point from the specific to the general, airport → city, region → country, country → continent, which reads naturally and keeps traversals such as “every airport in Europe” short.
3503 rows import comfortably in a single transaction. For a larger file, wrap the work in a batched call so Neo4j commits as it goes:
LOAD CSV WITH HEADERS FROM 'file:///airport-node-list.csv' AS row
CALL {
WITH row
MERGE (a:Airport {iata: row.iata})
// ... the rest of the statement above
} IN TRANSACTIONS OF 1000 ROWS;
Checking the result
A quick count confirms that the import produced what we expect, one node per airport plus the far smaller set of geography nodes:
MATCH (n)
RETURN labels(n) AS label, count(*) AS nodes
ORDER BY nodes DESC;
Figure 2 breaks the 8,677 total down by label, and the shape of that breakdown is the import working as intended. Airport is 3503, exactly one node per row of the CSV, which confirms that no airport was duplicated or dropped. Every label below it is smaller than 3503 because those values repeat across rows: 3409 cities means a few dozen cities have more than one airport, 1527 regions and 232 countries are plausible administrative counts, and the 6 continents are simply the six continent codes the file uses. Had we used CREATE instead of MERGE, every one of these rows would read 3503 instead.
The most useful check, though, is the schema itself. As in the Movie post, db.schema.visualization() draws the model rather than the data, one circle per label and one arrow per relationship type:
CALL db.schema.visualization();
Airport, City, Region, Country and Continent, joined by the four relationship types created by the import. Clicking a label shows its constraints; Continent carries the continent_code uniqueness constraint and its owned index.
Figure 3 is exactly the shape the import script describes, and reading it confirms three things at once. Airport sits at the centre with two outgoing arrows, IN_CITY and IN_REGION; the geography then chains upwards, Region → IN_COUNTRY → Country → ON_CONTINENT → Continent. There are no stray labels or relationship types, which means no typo created a :Countries or :IN_Country variant. Selecting a label shows its constraints in the details panel, so this one view also verifies that the uniqueness constraints from the previous section are attached to the right labels.
db.schema.visualization() reads the database’s token store rather than the data, so a label or relationship type that was created and later deleted can still appear until the store is recomputed. It describes what can exist, not what currently does.
And a single airport shows the whole pattern in one picture, with the numeric properties now genuinely numeric:
MATCH path = (:Airport {iata:'ATL'})-[*1..2]->()
RETURN path;
If something looks wrong and you want to start again, the constraints and the data can be cleared independently:
MATCH (n) DETACH DELETE n; // remove the data, keep the constraints
DROP CONSTRAINT airport_iata; // remove a constraint, if needed
Step 3: Projecting the graph for GDS
Everything so far has been ordinary Cypher, and it wrote to the database on disk. The GDS algorithms do not run against that stored graph. They run against an in-memory projection: a compact, read-only copy of just the labels, relationship types and properties an algorithm actually needs, held in the heap of the flights DBMS. This is why the flights service was given a 4G heap and the GDS plugin, and it is why every algorithm in the rest of this series starts by naming a projection rather than a pattern.
The canonical projection for this dataset is the airports together with the cities they serve:
CALL gds.graph.project(
'airports-cities',
['Airport', 'City'],
'IN_CITY'
)
YIELD
graphName, nodeProjection, nodeCount, relationshipProjection,
relationshipCount;
The three positional arguments are all there is to it: the name the projection is stored under (airports-cities), the node projection (every node labelled Airport or City), and the relationship projection (every IN_CITY relationship between them). The YIELD clause echoes back what was built, and nodeCount and relationshipCount are the two numbers worth reading, since a relationshipCount of 0 is the usual sign that the type was misspelled or that the relationships were never created.
Both node labels are passed as a list, because a projection must contain every node that its relationships connect: projecting IN_CITY without also projecting City would leave every relationship dangling, and GDS would drop it.
airports-cities projection built in Neo4j Browser. The result row reports a nodeCount of 6912 and a relationshipCount of 3503, and the whole projection was assembled in well under a second.
Both numbers in Figure 4 are worth checking against what we know about the data. The 3503 relationships are exactly one IN_CITY per airport, as expected since every row of the CSV names a city. The 6912 nodes are the 3503 airports plus 3409 distinct cities, which says that a few hundred cities serve more than one airport, the deduplication MERGE performed in Step 2 now visible as a number.
Adding properties and multiple types
The simple three-argument form is the shorthand. The full form replaces the second and third arguments with maps, which is what lets a projection carry several labels, several relationship types, node properties for the algorithm to read, and a relationship property to use as a weight:
CALL gds.graph.project(
'geography',
{
Airport: { properties: ['altitude', 'runways'] },
City: {},
Region: {},
Country: {}
},
{
IN_CITY: { orientation: 'NATURAL' },
IN_REGION: { orientation: 'NATURAL' },
IN_COUNTRY: { orientation: 'UNDIRECTED' }
}
)
YIELD graphName, nodeCount, relationshipCount;
The three settings that matter most:
propertieson a node. Only the properties listed here are copied into memory, and only numeric ones can be.altitudeandrunwaysare usable because we converted them withtoInteger()in Step 2; had we left them as the stringsLOAD CSVproduced, this projection would fail outright. That is the type-conversion mistake finally announcing itself.orientation.NATURALkeeps the stored direction,REVERSEflips it, andUNDIRECTEDstores both directions. The choice is not cosmetic: PageRank on aNATURALprojection measures something quite different from PageRank on anUNDIRECTEDone, and most community-detection algorithms expectUNDIRECTED.- A relationship property. A relationship can carry a numeric property in the same way,
{ IN_CITY: { properties: ['weight'] } }, which is what shortest-path and weighted-centrality algorithms read when you ask them to minimise a cost rather than a number of hops.
Managing projections
Projections live in memory and belong to the user who created them, so they survive between queries but not a database restart. Three procedures cover day-to-day use:
CALL gds.graph.list()
YIELD graphName, nodeCount, relationshipCount, memoryUsage;
CALL gds.graph.exists('geography')
YIELD exists;
CALL gds.graph.drop('geography')
YIELD graphName;
gds.graph.list() showing the single projection currently held in memory: airports-cities, with 6912 nodes, 3503 relationships, and a memoryUsage of 2027 KiB.
Figure 5 is the answer to “what is currently occupying the heap?”. Only one row is returned, airports-cities, and its counts match what was reported when the projection was built. The interesting column is memoryUsage: roughly 2 MB for a graph of almost 7000 nodes, a fraction of what the same data occupies on disk, because the projection stores only the topology and the properties we asked for. That compactness is the whole point of projecting, and it is also why keeping stale projections around is worth avoiding on a graph large enough to matter.
gds.graph.exists('geography') returning a single boolean column, TRUE.
Figure 6 shows the lighter-weight check. Where gds.graph.list() returns a row per projection with all its statistics, gds.graph.exists() answers one yes/no question about one name, which is what you want in a script before deciding whether to project or to reuse. The TRUE here confirms the geography projection from the previous section is still resident.
gds.graph.project fails if the name is already taken, so gds.graph.drop is the command you will reach for most often while iterating on a projection. Dropping one frees the heap immediately and never touches the stored graph.
Step 4: Running an algorithm on the projection
With a projection in memory, an algorithm is a single call. PageRank is the natural first one: it gives a node a high score when many other well-connected nodes point at it. Run it over the geography projection, which has to be built with all four relationship types so that rank has somewhere to flow:
CALL gds.graph.project(
'geography',
['Airport', 'City', 'Region', 'Country', 'Continent'],
['IN_CITY', 'IN_REGION', 'IN_COUNTRY', 'ON_CONTINENT']
);
CALL gds.pageRank.stream('geography')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS n, score AS pageRank
RETURN coalesce(n.iata, n.name, n.code) AS place,
labels(n)[0] AS type,
pageRank
ORDER BY pageRank DESC, place ASC
LIMIT 20;
The pattern here is the one every GDS algorithm follows:
.streamis the execution mode. It returns one row per node and writes nothing back. The alternatives are.write, which stores the score as a property on the stored graph,.mutate, which stores it on the projection so a later algorithm can read it, and.stats, which returns only summary numbers.YIELD nodeId, score. GDS works with internal numeric ids, not nodes, so the raw result is deliberately anonymous.gds.util.asNode(nodeId). This is the step that turns the projection’s id back into a real node from the stored graph, which is the only way to get at properties such asiataanddescr; those were never copied into the projection.coalesce(n.iata, n.name, n.code). The projection mixes labels and each one keeps its identifier in a different property, so a baren.iatawould returnnullfor every city and country.LIMIT 20. Without it the query streams all 8677 nodes.
AS at 107.99, with the United States the only country to break into the list at 38.41.
The result in Figure 7 is dominated by continents and large countries rather than airports, and that is the correct answer for this graph: every airport pushes rank towards its region, every region towards its country, and every country towards its continent, so rank accumulates at the top of the hierarchy. The ordering within that top group is informative in its own right, since it tracks how many airports feed each continent, AS ahead of EU, AF and NA. The one country in the list, US, is there because it has far more regions beneath it than any other. It is a useful sanity check precisely because the ranking is predictable, which tells us the call is wired up correctly.
Filtering to the airports
If you only want airports in the output, filter after converting the id back to a node rather than trying to filter inside the algorithm:
CALL gds.pageRank.stream('geography')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS n, score AS pageRank
WHERE n:Airport
RETURN n.iata AS iata, n.descr AS description, pageRank
ORDER BY pageRank DESC, iata ASC
LIMIT 10;
Bear in mind what these scores mean. They rank airports by their position in the geographic hierarchy, not by how busy they are, because that hierarchy is the only structure this dataset contains. Ranking airports by traffic would need a second file describing the connections between them, which is a topic for a later post.
Writing the scores back
.stream is fine for looking at a result once, but the scores vanish as soon as the query returns. Switching to the .write execution mode stores each score as a property on the stored graph, so it becomes ordinary data that any later Cypher query can read, filter and sort:
CALL gds.pageRank.write('geography', {
writeProperty: 'pageRank'
})
YIELD nodePropertiesWritten, ranIterations, didConverge;
writeProperty names the property to create, and the YIELD reports how many nodes were touched along with whether the algorithm converged before hitting its iteration limit. Every node in the projection gets the property, airports and geography alike.
Reading the scores back is then plain Cypher, with no GDS involved at all:
MATCH (a:Airport)
RETURN a.iata AS iata, a.descr AS description, a.pageRank AS pageRank
ORDER BY a.pageRank DESC, a.iata ASC
LIMIT 10;
Compare this with the streaming version above. There is no gds.util.asNode, because we are matching real nodes from the start, and the label filter is the MATCH (a:Airport) pattern rather than a WHERE clause applied afterwards. Filtering by label is also cheap here, since MATCH uses the label index instead of streaming all 8677 nodes and discarding most of them.
Because pageRank is now a stored property, it composes with everything else in the graph:
MATCH (a:Airport)-[:IN_REGION]->(:Region)-[:IN_COUNTRY]->(c:Country {code:'US'})
RETURN a.iata AS iata, a.descr AS description, a.pageRank AS pageRank
ORDER BY a.pageRank DESC, a.iata ASC
LIMIT 10;
.write genuinely modifies the database. Re-running it overwrites the property, and a different projection or a different algorithm writing to the same writeProperty will silently replace the earlier values. Clearing the scores is a single statement:
MATCH (n) WHERE n.pageRank IS NOT NULL REMOVE n.pageRank;