Querying Amazon Neptune with Gremlin

Querying Amazon Neptune with Gremlin

Load property graph data into Amazon Neptune and query relationships with Gremlin traversals.

Takahiro Iwasa
7 min read

Amazon Neptune is a managed graph database supporting Gremlin and SPARQL. The examples below focus on Gremlin traversals.

What Is a Graph Database?

Graph databases are optimized for storing and querying relationships between entities. They are useful when traversing interconnected data would require complex joins in a relational model.

In graph databases:

  • Vertices represent entities.
  • Edges define relationships between vertices.
  • Both vertices and edges can have properties (key-value pairs). This model is called a property graph.

Graph Representation

Transactions in Neptune

Neptune uses different transaction isolation levels for read-only and mutation queries. See the official documentation for details.

For read-only queries, Neptune uses snapshot isolation with MultiVersion Concurrency Control (MVCC). Each query reads from a snapshot taken at the start of the transaction, preventing dirty reads, non-repeatable reads, and phantom reads.

For mutation queries, Neptune uses READ COMMITTED isolation to prevent dirty reads. It also locks the record ranges being read to prevent non-repeatable reads and phantom reads.

Traversal Examples

Preparing Graph Data

This example uses vertices with an age property and edges with a weight property.

Load the sample data with the following commands. %%gremlin is a Jupyter Notebook magic command available in Neptune Workbench.

%%gremlin
// Drop existing data
g.V().drop()
%%gremlin
// Add Vertices
g.addV('person').property(id, 'A').property('age', 30)
.addV('person').property(id, 'B').property('age', 25)
.addV('person').property(id, 'C').property('age', 35)
.addV('person').property(id, 'D').property('age', 20)
.addV('person').property(id, 'E').property('age', 18)
.addV('person').property(id, 'F').property('age', 25)
.addV('person').property(id, 'G').property('age', 10)
.addV('person').property(id, 'H').property('age', 15)
%%gremlin
// Add Edges
g.V('A').addE('know').to(g.V('B')).property('weight', 1.0)
.V('A').addE('know').to(g.V('C')).property('weight', 0.9)
.V('A').addE('know').to(g.V('H')).property('weight', 0.5)
.V('B').addE('know').to(g.V('D')).property('weight', 0.8)
.V('B').addE('know').to(g.V('E')).property('weight', 0.4)
.V('C').addE('know').to(g.V('F')).property('weight', 0.5)
.V('C').addE('know').to(g.V('G')).property('weight', 0.6)
.V('D').addE('know').to(g.V('E')).property('weight', 0.7)
.V('H').addE('know').to(g.V('E')).property('weight', 1.0)
.V('H').addE('know').to(g.V('G')).property('weight', 1.0)

Example 1: Retrieving All Vertices

%%gremlin
// Extract Vertices
g.V()
.project('id', 'properties') // Projection
.by(id()).by(valueMap()) // valueMap returns properties of vertices.

Result:

RowData
1{'id': 'A', 'properties': {'age': [30]}}
2{'id': 'B', 'properties': {'age': [25]}}
3{'id': 'C', 'properties': {'age': [35]}}
4{'id': 'D', 'properties': {'age': [20]}}
5{'id': 'E', 'properties': {'age': [18]}}
6{'id': 'F', 'properties': {'age': [25]}}
7{'id': 'G', 'properties': {'age': [10]}}
8{'id': 'H', 'properties': {'age': [15]}}

Example 2: Traversing Connections

Retrieve all people aged 25 or older who are connected to 'A' within two hops:

%%gremlin
// Extract persons (entities) which are older than 25 years old and connected from A up to 2nd.
g.V('A')
.repeat(outE().inV()).times(2).emit() // Repeat traversal of adjacent vertices twice
.has('age', gte(25)) // Greater than or equal 25 years old
.project('id', 'age')
.by(id()).by(values('age'))

Result:

RowData
1{'id': 'B', 'age': 25}
2{'id': 'C', 'age': 35}
3{'id': 'F', 'age': 25}

Example 3: Filtering by Weight

Find vertices whose product of edge weights from 'A' exceeds 0.5:

%%gremlin
// Start traversal at A which extracts vertices that have a multiplied weight value greater than 0.5
g.withSack(1.0f).V('A') // Sack can be used to store temporary data
// Multiply a weight value of an out-directed edge by a sack value, and traverse all in-directed vertices
.repeat(outE().sack(mult).by('weight').inV().simplePath()).emit()
.where(sack().is(gt(0.5))) // A sack value greater than 0.5
.dedup() // deduplication
.project('id', 'weight')
.by(id).by(sack())

Result:

RowData
1{'id': 'B', 'weight': 1.0}
2{'id': 'C', 'weight': 0.9}
3{'id': 'D', 'weight': 0.8}
4{'id': 'G', 'weight': 0.54}
5{'id': 'E', 'weight': 0.5599…}

Visualizing Graphs

Neptune Workbench can visualize query results interactively. See the official documentation for details.

Visualizing the graph uses a similar traversal, with display options added:

%%gremlin -d T.id -de weight
// -d specifies the vertex property to display
// -de specifies the edge property to display
// Execute traversal from example 3
g.withSack(1.0f).V('A') // Sack is used to store temporary data
.repeat(outE().sack(mult).by('weight').inV().simplePath()).emit() // Traverse with edge weight
.where(sack().is(gt(0.5))) // Filter paths where the sack value > 0.5
.dedup() // Remove duplicate paths
.path() // Extract path data
.by(elementMap()) // Display properties of vertices and edges

Example Output:

RowData
1path[{<T.id: 1>: 'A', <T.label: 4>: 'person', 'age': 30}, {<T.id: 1>: '8ebe47fa-901b-c6d3-a11f-0a9bf0ce8aa2', <T.label: 4>: 'know', <Direction.IN: 2>: {<T.id: 1>: 'B', <T.label: 4>: 'person'}, <Direction.OUT: 3>: {<T.id: 1>: 'A', <T.label: 4>: 'person'}, 'weight': 1.0}, {<T.id: 1>: 'B', <T.label: 4>: 'person', 'age': 25}]
2path[{<T.id: 1>: 'A', <T.label: 4>: 'person', 'age': 30}, {<T.id: 1>: '7abe47fa-901c-c394-4bed-6dce7defa3f9', <T.label: 4>: 'know', <Direction.IN: 2>: {<T.id: 1>: 'C', <T.label: 4>: 'person'}, <Direction.OUT: 3>: {<T.id: 1>: 'A', <T.label: 4>: 'person'}, 'weight': 0.9}, {<T.id: 1>: 'C', <T.label: 4>: 'person', 'age': 35}]

After running the query, open the Graph tab in Neptune Workbench to visualize the result. The interface supports dragging and zooming to explore the graph.

Appendix 1: Tree Structures in Relational Databases (RDB)

Tree structures can be modeled in relational databases (RDBs) in several ways, so a graph database is not required for every hierarchical dataset.

However, the Naive Tree model has limitations, as discussed in “SQL Antipatterns”.

  • Naive Tree
    • Expression: t1.id = t2.parent_id
    • Pros
      • Simple implementation (Adjacency List)
    • Cons
      • Difficult to extract non-adjacent nodes
      • Complex SQL
      • Low performance
  • Path Enumeration
    • Expression: path LIKE '1/2%'
    • Pros
      • Simplifies extracting non-adjacent nodes
    • Cons
      • Complex INSERT/UPDATE/DELETE operations
      • Limited by column max length
  • Nested Sets
    • Expression: Left > 1 AND Right < 6
    • Pros
      • Efficient for querying non-adjacent nodes
    • Cons
      • Complex INSERT/UPDATE operations
      • Limited by column max length
      • Non-intuitive structure
  • Closure Table
    • Expression: Separate table for the tree
    • Pros
      • Efficient for querying all nodes
      • Handles INSERT/UPDATE/DELETE easily
    • Cons
      • Data can grow significantly in size
      • INSERT/UPDATE/DELETE can have low performance

In a relational model, queries become more complex as the number of relationship levels increases. Representing and traversing highly connected data with rows and columns may require recursive queries or repeated joins.

Appendix 2: Transactions

Dirty Read

Tx1 updates a row, Tx2 reads the uncommitted value, and then Tx1 fails or rolls back. Tx2 has read data that was never committed.

Non-repeatable Read

Tx1 reads a row, Tx2 updates or deletes it and commits, and then Tx1 reads it again. Tx1 sees a different committed value from its first read.

Phantom Read

Tx1 reads a set of rows, Tx2 inserts or deletes matching rows and commits, and then Tx1 repeats the query. Tx1 sees a different set of rows from its first read.

Isolation Levels

LevelDirty ReadNon-repeatable ReadPhantom Read
READ UNCOMMITTEDPossiblePossiblePossible
READ COMMITTEDNot possiblePossiblePossible
REPEATABLE READNot possibleNot possiblePossible
SERIALIZABLENot possibleNot possibleNot possible

Conclusion

The examples load a small property graph into Neptune and use Gremlin to query multi-hop relationships and cumulative edge weights.

In example 2, repeat(outE().inV()).times(2).emit() traverses up to two hops from vertex A. In example 3, withSack(1.0f) carries the cumulative weight along each path.

Relational tree models involve different tradeoffs: adjacency lists are simple to update but require more complex queries for distant nodes, while closure tables improve those queries at the cost of additional storage and write processing.

When relationships are a primary part of the query, a graph model can express these traversals more directly than repeated relational joins.

About the author

Takahiro Iwasa

Takahiro Iwasa

Software Developer

This blog shares technical notes from hands-on projects—architecture, implementation, and AWS service integrations.