Graph Data Science with Neo4j: The Airport Dataset

The starting point of a Graph Data Science tutorial built on an airport dataset. This post starts the dedicated flights DBMS with Docker Compose, makes the CSV visible to Neo4j, loads the airport node list with LOAD CSV, and projects the result into memory for GDS.

graph-rag
neo4j
graph-data-science
Author

Mei-Chin Pang

Published

August 28, 2026

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 not

Then open http://localhost:7475 and log in as neo4j / flightspassword. When you are finished:

docker compose stop flights       # stop it, keep the data
Note

The 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, the iata and icao codes, city, a longer descr, region, runways, longest, altitude, country, continent, and its lat/lon coordinates.
  • The types. LOAD CSV returns every field as a string, quotes in the file or not. That means runways, longest, altitude, lat, and lon arrive as text such as "5" and "33.6366996765137", and will have to be converted with toInteger() and toFloat() when we create the nodes. Skipping that conversion is the single most common mistake in a LOAD CSV import: 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
Important

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 numeric id column would work too, but iata is what people actually recognise and what other flight datasets join on.
  • City needs two properties. City names are not globally unique, so a single-property constraint on name would 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 (only IS NODE KEY is 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: Running the import in Neo4j Browser. The result line reports 8,677 nodes, 8,771 relationships and 36,607 properties created, and the Database information panel on the left now lists the five new labels, the four relationship types, and every property key the script wrote.

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() and toFloat() turn the CSV’s strings into real numbers, so ORDER BY a.altitude sorts numerically instead of alphabetically.
  • point(). Rather than storing lat and lon as two loose floats, we store a single spatial point. Neo4j can then compute great-circle distances with point.distance(a.location, b.location), which turns “how far apart are these two airports?” into a one-line query.
  • ON CREATE SET versus SET. As in the Movie script, ON CREATE SET writes the properties only when the node is genuinely new. Re-running the import therefore leaves existing airports untouched. Use plain SET instead if you want a re-run to refresh the properties from an updated file.
  • MERGE on the relationships. MERGE (a)-[:IN_CITY]->(c) looks for the whole pattern first, so the second airport in Atlanta reuses the existing City node instead of creating a second one. This is precisely the duplicate-node problem we ran into with CREATE in the MERGE post.
  • 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.
Tip

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: Node counts per label after the import: 3503 airports, 3409 cities, 1527 regions, 232 countries and 6 continents.

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();
Figure 3: The airport graph schema: five labels, 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, RegionIN_COUNTRYCountryON_CONTINENTContinent. 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.

Note

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.

Figure 4: The 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:

  • properties on a node. Only the properties listed here are copied into memory, and only numeric ones can be. altitude and runways are usable because we converted them with toInteger() in Step 2; had we left them as the strings LOAD CSV produced, this projection would fail outright. That is the type-conversion mistake finally announcing itself.
  • orientation. NATURAL keeps the stored direction, REVERSE flips it, and UNDIRECTED stores both directions. The choice is not cosmetic: PageRank on a NATURAL projection measures something quite different from PageRank on an UNDIRECTED one, and most community-detection algorithms expect UNDIRECTED.
  • 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;
Figure 5: 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.

Figure 6: 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:

  • .stream is 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 as iata and descr; 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 bare n.iata would return null for every city and country.
  • LIMIT 20. Without it the query streams all 8677 nodes.
Figure 7: The top rows of the PageRank stream. Continents take six of the seven places, led by 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;
Warning

.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;