DynamoDB’s adjacency list model is surprisingly efficient for representing graph-like relationships, often outperforming traditional relational database approaches for specific query patterns.
Let’s say you’re building a social network and need to represent "friendships." In DynamoDB, you wouldn’t have a friendships table. Instead, you’d model this within a users table using an adjacency list pattern.
Here’s a simplified users table structure:
{
"TableName": "Users",
"KeySchema": [
{ "AttributeName": "PK", "KeyType": "HASH" },
{ "AttributeName": "SK", "KeyType": "RANGE" }
],
"AttributeDefinitions": [
{ "AttributeName": "PK", "AttributeType": "S" },
{ "AttributeName": "SK", "AttributeType": "S" }
]
}
Now, let’s populate it with some data. A user, "Alice," might have the following items:
// Alice's primary user record
{ "PK": "USER#Alice", "SK": "PROFILE", "name": "Alice Wonderland" }
// Alice's friendships (outbound edges)
{ "PK": "USER#Alice", "SK": "FRIEND#Bob", "friendship_since": "2023-01-15" }
{ "PK": "USER#Alice", "SK": "FRIEND#Charlie", "friendship_since": "2023-02-20" }
// Bob's primary user record
{ "PK": "USER#Bob", "SK": "PROFILE", "name": "Bob The Builder" }
// Bob's friendships (outbound edges)
{ "PK": "USER#Bob", "SK": "FRIEND#Alice", "friendship_since": "2023-01-15" }
{ "PK": "USER#Bob", "SK": "FRIEND#David", "friendship_since": "2023-03-10" }
// Charlie's primary user record
{ "PK": "USER#Charlie", "SK": "PROFILE", "name": "Charlie Chaplin" }
// Charlie's friendships (outbound edges)
{ "PK": "USER#Charlie", "SK": "FRIEND#Alice", "friendship_since": "2023-02-20" }
Notice the pattern:
PK(Partition Key): Always starts withUSER#followed by the user’s ID. This groups all items related to a specific user together.SK(Sort Key): This is where the adjacency list magic happens.PROFILEidentifies the user’s main profile information.FRIEND#<other_user_id>identifies an outgoing edge (a friendship) to another user.
To find all of Alice’s friends, you’d perform a query on the Users table:
- Key Condition:
PK = "USER#Alice" - Range Condition:
SK begins_with "FRIEND#"
This query efficiently retrieves only the items representing Alice’s friendships. Because all items for "Alice" are co-located within the same partition, this query is extremely fast and cost-effective.
What if you want to find mutual friends between Alice and Bob? You can use a query with a composite SK condition. For example, if you wanted to find if Alice is friends with Bob, you’d query for PK = "USER#Alice" and SK = "FRIEND#Bob". If that item exists, Alice is friends with Bob. To find mutual friends, you’d need to perform two queries and check for intersection, or employ more complex strategies for larger datasets.
The real power comes when you need to model more complex relationships, like "follows," "likes," or "shares." You simply extend the SK pattern. For instance, to represent Alice liking a post:
{ "PK": "USER#Alice", "SK": "LIKES#POST#12345", "timestamp": "2023-04-01T10:00:00Z" }
And to represent Bob following Charlie:
{ "PK": "USER#Bob", "SK": "FOLLOWS#USER#Charlie", "timestamp": "2023-04-02T11:30:00Z" }
This flexible SK allows you to model diverse graph structures within a single table. The "type" of relationship is encoded directly in the sort key, enabling targeted queries.
The common counter-intuitive aspect of this pattern is how a single table can represent such varied, interconnected data. Most relational thinkers expect separate tables for each relationship type. Here, the SK acts as a dynamic schema, allowing you to define relationship types on the fly as you insert data. It’s not about defining columns for "friend," "follower," "liker" in advance; it’s about defining the edge itself as an item where the SK uniquely identifies the target of that edge. This makes schema evolution for new relationship types trivial – you just start using new SK prefixes.
This model excels at "traversing" your graph in one direction (e.g., finding all friends of a user). For queries that require traversing edges in both directions (e.g., finding who is friends with Bob and who Bob is friends with), you’d typically denormalize by adding reciprocal items (e.g., {"PK": "USER#Bob", "SK": "FRIEND#Alice", ...} for Bob’s record to mirror Alice’s {"PK": "USER#Alice", "SK": "FRIEND#Bob", ...}). This increases write complexity but dramatically speeds up bidirectional queries.
The next common challenge is efficiently querying for relationships between two arbitrary nodes when you don’t know which node is the PK.