Amazon DynamoDB Deep Dive. Chapter 2: Tables, Data types, Indexes, Capacity Units
By: Karen Tovmasyan
Tags: AWS, Dynamo, Cloud, Serverless, NoSQL

Welcome back! It has been a long while since I wrote the first chapter of this Deep Dive series, so I will not bother you with a long preface.

In this chapter:

Tables, Items, Table/Item operations DynamoDB data types Local and Global Secondary Indexes Capacity Units, Reservation, Auto-Scaling

Let’s jump in!

Tables

Before we begin, we need to have a detailed understanding of how DDB tables differ from tables in traditional relational databases.

In classic RBDMS, the database and its tables are stored in memory or on a disk in single or multiple files in the form of a B-tree/LSM-tree data structure. The exact layout, access patterns, and organization depend on the database implementation and are different, if not unique, for numerous engines.

With DynamoDB, it is way different. It stores the data in a key-value structure called Item. Each Item belongs to a table, but the items replicate across DDB storage nodes. The term Table acts as a logical grouping of the items. It allows access to the data using IAM policies (there is also a way to enable granular item-level access — read more here) and provides a primary key constraint prerequisite for CRUD operations.

The table has a Primary Key (also considered a Primary Key Index). It supports Secondary Indexes (we’ll cover them later in this chapter) and other features such as TTL for Items, Streams, and various consistency operations, and so on.

To create a Table, we need to provide some mandatory arguments, such as Primary Key attributes (Data and Key types) and the table name. Let’s pick the same example from the previous chapter.

$ aws dynamodb create-table \

--table-name characters \

--attribute-definitions \

AttributeName=characterId,AttributeType=S \

AttributeName=playerId,AttributeType=S \

--key-schema \

AttributeName=playerId,KeyType=HASH \

AttributeName=characterId,KeyType=RANGE \

--billing-mode PAY_PER_REQUEST

What I do here is create a table with a composite primary key structure. The Hash key (also known as a Partition Key) and Range key (also known as Sort Key) are not considered unique - in my example, the player can have multiple characters. Their attributes (for my RDBMS friends — treat attributes as columns) must have a particular data type and are String in my case.

The Composite Primary key consists of two attributes, and its combination must be unique. That said, I can have two items with the same characterId, but different playerId (one player “sold” his character to another), or two items with the same playerId, but different characterIds (one player owns multiple characters). This Key pattern is quite common for DynamoDB applications.

There is also a more straightforward approach for a table organization called a “simple table.” In this case, your Primary Key consists of only the Partition (Hash) Key, like in the example below:

$ aws dynamodb create-table \

--table-name shoppingList\

--attribute-definitions \

AttributeName=good,AttributeType=S \

--key-schema \

AttributeName=good,KeyType=HASH \

--billing-mode PAY_PER_REQUEST

Let’s say you need a simple key-value table for a shopping list, and you know that you only interact with one item at a time: add some goods to buy (five apples, ten oranges) or mark as “bought.” You don’t need a Sort (Range) Key, as there is no case of Query operation — you don’t plan to retain the shopping list once you leave the supermarket.

With these requirements and access patterns, your item might look like the following:

{

"good": "apple",

"number": 10,

"bought": True|False

}

Knowing just the theory is not enough to have an understanding of DynamoDB. Let’s now see how we can work with tables!

Working with tables

We’ll start with a simple table pattern. Let’s try to add such item to our shopping list (with Python and Boto3):

>>> import boto3

>>> ddb = boto3.resource('dynamodb')

>>> shopping_list = ddb.Table('shoppingList')

>>> resp = shopping_list.put_item(

... Item={

... "good": "apple",

... "number": 10,

... "bought": False

... }

... )

Let’s add some more items:

>>> goods = [

... { "good": "orange", "number": 10, "bought": False },

... { "good": "tomato", "number": 7, "bought": False },

... { "good": "cucumber", "number": 1, "bought": False }

... ]

>>> for good in goods:

... _ = shopping_list.put_item(Item=good)



Let’s see what’s in there on our shopping list. As I want all the items on my table, I will run a Scan operation. But first, I must warn you.

SCAN IS VERY INEFFECTIVE, EXPENSIVE, AND SLOW OPERATION. YOU SHOULD AVOID USING IT IN PRODUCTION ENVIRONMENT AT ALL COSTS (more details in the “Capacity Units” section)

I feel I am obliged to say this, as I have seen numerous examples of disastrous DynamoDB workloads because of Scans. As my list is small and I need everything on my table, I will go through it.

>>> shopping_list.scan()['Items']

[{'good': 'orange', 'bought': False, 'number': Decimal('10')}, {'good': 'cucumber', 'bought': False, 'number': Decimal('1')}, {'good': 'apple', 'bought': False, 'number': Decimal('10')}, {'good': 'tomato', 'bought': False, 'number': Decimal('7')}]

That’ll do! Now, I have acquired all the oranges I needed to. Let me set bought=True and move on with my shopping cart. One way to do this is to use PutItem API call, mentioning the Primary key.

>>> shopping_list.put_item(Item={"good": "orange", "bought": True})

# DDB output...

>>> shopping_list.scan()['Items']

[{'good': 'orange', 'bought': True},

# the rest of the output...

]

Wait. What?! Where did the number go???

You see, you can use PutItem both for inserting and replacing the existing item. But if we want to change only a single attribute, we should use the UpdateItem call to avoid other attributes' deletion.

>>> shopping_list.update_item(

... Key = { 'good': 'orange' },

... AttributeUpdates = {

... 'number': { 'Value': 10, 'Action': 'PUT' },

... 'bought': { 'Value': True, 'Action': 'PUT' }

... }

... )

# API response...

>>> shopping_list.scan()['Items']

[{'good': 'orange', 'bought': True, 'number': Decimal('10')},

# the rest of the output...]

Looks much better right?

You noticed that I ran the Scan operation multiple times, while I needed only one item. As mentioned above, the Scan is very ineffective, especially when you don’t need the table's entire content. To get a single item, there is a much better call GetItem .

>>> shopping_list.get_item(Key={'good': 'orange'})['Item']

{'good': 'orange', 'bought': True, 'number': Decimal('10')}

This call also allows me to get not all the attributes, but only specific ones. Let’s say I want to check if I already bought the oranges, so I am only interested in the bought attribute.

>>> shopping_list.get_item(

... Key = { 'good': 'orange' },

... AttributesToGet = [ 'bought' ]

... )['Item']

{'bought': True}

The argument AttributesToGet is a human-readable way to get specific attributes, but it is deprecated, and we should use another one called ProjectionExpression .

>>> shopping_list.get_item(

... Key = { 'good': 'orange' },

... ProjectionExpression = 'bought'

... )['Item']

{'bought': True}

>>>

It does not look so readable, but unlike AttributesToGet projection, expression allows you to obtain nested attributes (Oh yes, you can put JSON into JSON!). But more on this later, when we get to the Data Types.

We need to look into one more operation, but it does not work with a simple Primary Key structure. The call Query can go through the Sort (Range) Keys and obtain all the items which belong to that key. To demonstrate that, let’s bring back our multiplayer game.

Our characters table contains a Composite Key, the Partition Key is the actual player identifier, and the Sort Key is the character's identifier. We know that our players can have multiple characters. Let’s create a dataset.

>>> characters = ddb.Table('characters')

>>> characters.put_item(

... Item = { 'characterId': 'char1', 'playerId': 'player1' }

... )

>>> characters.put_item(

... Item = { 'characterId': 'char2', 'playerId': 'player1' }

... )

>>> characters.scan()['Items']

[{'characterId': 'char2', 'playerId': 'player1'},

{'characterId': 'char1', 'playerId': 'player1'}]

Let’s add a few characters for another player.

>>> characters.put_item(Item={'characterId': 'char3', 'playerId': 'player2'})

>>> characters.put_item(Item={'characterId': 'char4', 'playerId': 'player2'})

>>> characters.put_item(Item={'characterId': 'char5', 'playerId': 'player2'})

>>> characters.scan()['Items']

[{'characterId': 'char3', 'playerId': 'player2'},

{'characterId': 'char2', 'playerId': 'player1'},

{'characterId': 'char4', 'playerId': 'player2'},

{'characterId': 'char5', 'playerId': 'player2'},

{'characterId': 'char1', 'playerId': 'player1'}]

Running Scan returns all the items in the table. If we would like to get only characters of player1 and we’d live in an RDBMS world, we’d run something like:

>>> p1_chars = [c for c in characters.scan()['Items']

... if c['playerId'] == 'player1']

>>> p1_chars

[{'characterId': 'char2', 'playerId': 'player1'},

{'characterId': 'char1', 'playerId': 'player1'}]

But again, this will consume a lot of Read Capacity Units when running on a bigger dataset (I am still obtaining all the items). The most effective operation for this case is Query . Let’s try it out:

# This module is needed to perform key value check.

>>> from boto3.dynamodb.conditions import Key

>>> characters.query(

... KeyConditionExpression=Key('playerId').eq('player1')

... )['Items']

[{'characterId': 'char1', 'playerId': 'player1'},

{'characterId': 'char2', 'playerId': 'player1'}]

We can perform plenty of operations using Key type and conditional methods (for Python, you can find them here).

Before we move on to the next section, I’d like to go through the DynamoDB limits quickly, and you must be aware of:

Maximum 256 tables per account per region

Maximum 40 000 read/write request units per table (per second)

No limit on table size

Max Partition Key size is 2Kb, min. is 1 byte (can not be increased)

Max Sort key size is 1Kb, min. is 1 byte (can not be increased)

Max Item size is 400Kb (can not be increased)

Of course, there are more limits, and you should look them up here.

Let’s cover another critical topic. Data Types, e.g., what we can store in DynamoDB!

Data types

The list is quite simple; take a look.

Scalar

Numbers, binary, Boolean, null. Ye, no rocket science here.

Document

A.k.a. JSON in JSON in JSON (JSONSEPTION!).

Keep in mind that the maximum depth of such type is 32 (You did check the limits, didn't’ you?)

Set

Set types (not to be mistreated as List!) consist of unique numeric, string, or byte value. Keep in mind that the order of items in the Set is not preserved!

Not too much to discuss here, so let’s move on. Shall we?

Indexes

Now, this is where the stuff gets serious.

Indexes are well-known structures in the relational databases world. Consider the following SQL query.

SELECT id, name, age FROM users WHERE name = 'John Doe';

When we execute this query against a database, it will go through each row and match it against our WHERE clause. This operation has linear complexity (e.g., O(n)) and may take a lot of time when we have tens of millions of rows in a table. To improve performance, we create an index on the column name so that the query executor will search in the index to find all matching entries.

Having similar names and implementation, Indexes in DynamoDB serve a different purpose. They are used with a Query operation but act as an addition to the existing Composite (Partition + Sort) Key structure. To explain this adequately, I need a more complex example, rather than a shopping cart.

Let’s take a look at our game characters again. Each player owns one or more characters, and these characters have specific attributes. Take a look at the following pseudo-code:

type Player {

id: string

region: string

server: string

email: string

} type Character {

id: string

player: Player

race: string

class: string

health: number

mana: number

strength: number

speed: number

level: number

}

There are several ways we can implement this data model on DynamoDB, but for the sake of this section and to introduce you to the Single-table design, I will follow not a Document database approach, but a Wide-Column store one. :)

Item = {

'playerId': 'string', # partition key

'characterId': 'string', # sort key

'playerRegion': 'string',

'playerEmail': 'string',

'currentServer': 'string',

'race': 'string',

'class': 'string',

'health': 123,

'mana': 123,

'strength': 123,

'speed': 123,

'level': 10

}

In this way, my sample item will look as follows:

{

'playerId': '7877e1b90fe2',

'characterId': '74477fae0c9f',

'playerRegion': 'us',

'playerEmail': 'foo@bar.com',

'currentServer': 'srv01.us-east.bestrpg.com',

'race': 'human',

'class': 'knight',

'health': 9000,

'mana': 10,

'strength': 42,

'speed': 23,

'level': 7

}

If I want to get all the stats and meta-info of a particular character, I just run GetItem specifying player and character identifiers. But what can I do if I need to send a notification on server maintenance to all the players who are currently there? Or if I need to send a tournament invite to all the characters who’s level is above 15? Or I want to send promo skins to all the knights who play in the US?

The number of cases can be humongous, but it’ll be too costly to run Scans on the table. The solution to these problems is Secondary Indexes, Local and Global.

Local Secondary Indexes

Let’s say I want to check all the characters that the player has on a particular server. The first option would be to run a Query with a Partition Key and then do similar activities, as we did with Scan and Python’s list comprehension.

>>> srv = 'srv01.us-east.bestrg.com'

>>> player_id = '7877e1b90fe2'

>>> on_server = [ c for c in characters.query(KeyConditionExpression=Key('playerId').eq(player_id))['Items'] if c['currentServer'] == srv ]

This call is better than Scan but is still ineffective — we get all the characters and then filter them by the server. Too many unnecessary API calls.

That is where Local Secondary Indexes kick in. I can create an Index on an attribute currentServer and use the Query .

>>> characters.query(

... KeyConditionExpression = Key('playerId').eq(player_id) &\

... Key('currentServer').eq(srv)

... )['Items']

But there is one tiny problem: you can only create Local Secondary Indexes (LSIs) with the table, and at the moment of this writing, it is impossible to create an LSI over an existing table. So I will have to delete a table and recreate it again with the LSI I need.

$ aws dynamodb delete-table --table-name characters

$ aws dynamodb create-table \

--table-name characters \

--attribute-definitions \

AttributeName=characterId,AttributeType=S \

AttributeName=playerId,AttributeType=S \

AttributeName=currentServer,AttributeType=S \

--key-schema \

AttributeName=playerId,KeyType=HASH \

AttributeName=characterId,KeyType=RANGE \

--billing-mode PAY_PER_REQUEST \

--local-secondary-indexes \

'IndexName=server,KeySchema=[{AttributeName=playerId,KeyType=HASH},{AttributeName=currentServer,KeyType=RANGE}],Projection={ProjectionType=KEYS_ONLY}'

After recreating the table and adding the items, let’s run the Query . The only addition to the call above is to add the actual Index name — so DynamoDB is aware that I want to query the Index, not the Table.

>>> characters.query(

... IndexName='server',

... KeyConditionExpression = Key('playerId').eq(player_id) &\

... Key('currentServer').eq(srv)

... )['Items']

[{'characterId': '74477fae0c9f', 'playerId': '7877e1b90fe2', 'currentServer': 'srv01.us-east.bestrpg.com'}]

Note that I obtain only three attributes. I have specified to project only keys — character and player identifiers and current server hostname. It is enough for my case, but you must be aware of this as if you would like to get more attributes in a single call, you have to either project the attributes to the index or to run additional GetItem calls.

Another limitation is that we can use a different Sort key. Still, the Partition key must remain the same, making LSIs more an additional filtering option than an excellent addition for access patterns.

Last but not least — you can only have 5 LSIs per table! And you can’t either add or remove indexes from the table after its creation.

Now let’s take a look at a more flexible feature — Global Secondary Indexes or GSIs.

Global Secondary Indexes

If LSI is like a regular B-tree or Bitmap index in relational DB, GSI can be considered a materialized view. While GSI is used to query the data from the same table, it has several pros against LSI:

The partition key can be different!

You can create a GSI for an existing table!!

table!! And you can then delete it!!!

GSIs have their capacity units, e.g., their performance is not strictly related to the table performance

Consider the following example. I have a table with characters and players. These players “belong” to a region and have an email. What I want to do is to send “Happy Xmas” greetings to all the players in the US. I already know that using a Scan operation is possible but ineffective. Instead, I will create a GSI that takes playerRegion as a Partition Key and currentServer as a Sort Key.

To do so, I will perform an UpdateTable operation.

$ cat index.json

[

{

"Create": {

"IndexName": "region",

"KeySchema": [

{

"AttributeName": "playerRegion",

"KeyType": "HASH"

},

{

"AttributeName": "currentServer",

"KeyType": "RANGE"

}

],

"Projection": {

"ProjectionType": "ALL"

}

}

}

]

$ aws dynamodb update-table \

--table-name characters \

--attribute-definitions \

AttributeName=playerRegion,AttributeType=S \

AttributeName=currentServer,AttributeType=S \

--global-secondary-index-updates \

file://index.json

Note the difference in a Projection field. As I want to get different attributes (example to be followed), I am projecting all of them.

I will see a lengthy description of my table with the latest changes I’ve made in the output. Let’s give it a shot:

Okay, another example: I want the characters identifiers of all the knights on the server.

>>> characters.query(

... IndexName='region',

... KeyConditionExpression=Key('playerRegion').eq('us') &\

... Key('currentServer').eq('srv01.us.bestrpg.com'),

... FilterExpression= Attr('class').eq('knight'),

... ProjectionExpression='characterId'

... )['Items']

[{'characterId': '74477fae0c9f'}]

>>>

To do so, I am querying the same index, but I also perform filtering on the attribute… And that’s also ineffective cause I am getting all the items, matching the Primary Key pattern, and then filter them locally.

But I can solve this by creating another GSI with a class attribute as a Partition Key and a currentServer as a Sort Key.

The capabilities of GSI are out of bounds and are crucial for the implementation of Single-Table design. You most likely have heard about ST design before, and I will cover that in Chapter 4. Meanwhile, enjoy the brilliant keynote from Rick Houlihan.

On top of that, GSIs have their capacity units, which allows us to offload the source table. And as I’ve been talking a lot about Capacity Units, now is the time to explain what they are.

Capacity Units

The benefits of managed, serverless AWS offerings are that we can measure the cost by a particular action and not only by a compute time. In regards to DynamoDB, we pay for the storage and the requests we perform against the table.

We treat these requests as Capacity Units — Dynamo’s load and request atomic items like IO operations. There are Write Capacity Units (WCU) and Read Capacity Units (RCU).

As you might guess, when you write to the table ( PutItem , DeleteItem , UpdateItem ) you consume WCUs, and one WCU is 1 (one) item of max 1 (one) Kb size. So if you write an item a size of 1.5Kb — 2 WCUs. Two items a size of 100 bytes — 2 WCUs.

1 (one) RCUs is 1 item maximum of max 4 (four) Kb size in a strong consistency or two items in an eventual consistency (we’ll cover DynamoDB consistency in the next chapter) of max 4 Kb s.

When using DynamoDB in on-demand mode (e.g. PAY_PER_REQUEST ) we are billed for each R/WCU consumed. This brings us to the importance of using Query against Scan.

You see, no matter how you filter the results of the Scan call, you will pay for every single item obtained from the table (even if you don’t process it afterward). So if you have a table with a million items, each Scan will cost you 0.25 USD before Tax. That may not sound too expensive, but imagine if you perform tens of hundreds of such calls every minute (or even a second!), and all that to obtain a row or two.

Contrary to this, Query calls are billed for as many Items as they have accessed. Thus, if you have a million Items, and only ten of them match the request pattern, you will pay only 0.0000025 USD per call. Much cheaper, eh?

Another option to use DDB is provisioned capacity mode, e.g., you define how many R/WCUs you want to have straight and pay a fixed amount every month. The capacity units are provided per second, and if you send more requests in the second than you have provisioned, DynamoDB will throttle you (AWS SDKs usually have built-in retires and back-offs).

Usage of provisioned W/RCUs should be done only when the load is determined, you don’t expect any spikes, and your application tolerates throttling. It is possible to change the billing settings from on-demand to provisioned and backward but applies both to Read and Write CUs (e.g., you cannot have on-demand for writes and provisioned mode for reads).

Another thing to mention that LSIs consume table’s CUs, but GSIs have their own. While the table and GSI can have different R/WCU settings, both have to be either provisioned or paid per request.

To update the table to a provisioned billing mode, I will run an UpdateTable call.

# Note the difference in the index settings.

# This time I use Update structure and only

# ProvisionedThroughput settins $ cat index.json

[

{

"Update": {

"IndexName": "region",

"ProvisionedThroughput": {

"ReadCapacityUnits": 10,

"WriteCapacityUnits": 1

}

}

}

]

$ aws dynamodb update-table \

--table-name characters \

--billing-mode PROVISIONED \

--provisioned-throughput \

ReadCapacityUnits=1,WriteCapacityUnits=1 \

--global-secondary-index-updates \

file://index.json

Now I can perform one write per second to both table and GSI (Yes, you can also write to an index!), one read for a table, and ten reads for a GSI.

If I want to change the settings back to on-demand, I only need to run the same UpdateTable call specifying --billing-mode PAY_PER_REQUEST .

Reservation and Auto-Scaling

Consider having a stable workload seeing upfront towards a year, and you want to save some costs. In this case, you can purchase Reserved Capacity Units, which gives you some guaranteed lower annual price.

Another requirement is to have an auto-scaling on capacity units. While this doesn’t come out of the box purely for DynamoDB, we can leverage Application Auto-Scaling.