This website uses cookies

Read our Privacy policy and Terms of use for more information.

Keyspaces speaks Cassandra's wire protocol without running Cassandra. That one sentence explains every surprise you will hit, and this article walks the surprises with a keyspace, a table, a data load and a probe script you run yourself.

What it actually is

Amazon Keyspaces (for Apache Cassandra) is a serverless wide-column database that accepts Cassandra Query Language (CQL) over TLS on port 9142. The docs say it is compatible with the CQL 3.11 API, backward-compatible with 2.x, and that you point an existing Cassandra driver or cqlsh at a regional endpoint such as cassandra.us-east-1.amazonaws.com and carry on.

The mental model the docs bury: it is not Cassandra with the nodes hidden. It is a different storage engine that implements enough of Cassandra's surface for drivers to work. There are no nodes, no JVM, no compaction, no repair. Settings for compaction, compression, caching, garbage collection and bloom filters are accepted and ignored. What you keep is the data model (partition keys, clustering columns, wide rows) and the query language. What you lose is everything that lives below the query language, which is both the point and the catch.

If you have a Cassandra cluster and you are tired of operating it, Keyspaces removes the operating. If you have a Cassandra cluster and you depend on secondary indexes, materialized views, user-defined functions or serial consistency, Keyspaces will say no, and it will say it at runtime.

The model

The data model is the Cassandra one. A table has a partition key (compound allowed) that decides where a row lives, and clustering columns that decide sort order inside the partition. There are no joins, so you design one table per access pattern and denormalize. The docs describe the same thing: partitions on disk, the partition key controlling distribution, clustering columns controlling order.

What AWS operates: the storage, the partitions, three copies of your data across multiple Availability Zones, scaling, patching, and encryption at rest (enabled automatically for new tables). What you operate: your schema, your partition key choices, your capacity mode, and your retry logic.

Four contracts worth memorizing.

Consistency. Writes are durably stored using LOCAL_QUORUM before they are acknowledged. Reads support exactly three levels: ONE, LOCAL_ONE and LOCAL_QUORUM. The docs list EACH_QUORUM, QUORUM, ALL, TWO, THREE, ANY, SERIAL and LOCAL_SERIAL as unsupported and say they result in exceptions. The docs do not name the exception, so do not write code that matches on a class name you invented.

Lightweight transactions. Compare-and-set works on INSERT, UPDATE and DELETE, and the functional-differences page states there is no performance penalty for using them. That is a real divergence from Cassandra, where LWTs are famously expensive.

Batches. Logged batches take up to 100 statements, unlogged batches up to 30, and only INSERT, UPDATE and DELETE are allowed inside. The quotas page adds a 4 MB cap on a logged batch and a 50-statement cap when static columns are involved.

DDL is asynchronous. Creating or dropping keyspaces, tables and types returns immediately, and the object is not usable until its status says so. Your provisioning code must poll. Prepared statements work for reads and writes but not for DDL.

Authentication is IAM. There are no Cassandra roles or passwords to configure; Cassandra's security commands are not supported. Drivers authenticate with a SigV4 plugin (cassandra-sigv4 for Python) using your normal AWS credentials, and every client connection requires TLS.

Two capacity modes: on-demand (pay per request) and provisioned (set read and write capacity per second, optionally with auto scaling). You can switch a table's mode once per day.

When to use it, when not to

Amazon Keyspaces

Self-managed Cassandra

Amazon DynamoDB

Query language

CQL 3.11 subset

Full CQL

PartiQL / native API

Operations

None

Nodes, repair, compaction, upgrades

None

Secondary indexes, UDFs, materialized views

No

Yes

Global and local secondary indexes

Serial / QUORUM consistency

No

Yes

Strong or eventual reads, transactions API

Existing Cassandra drivers

Yes, with SigV4 and TLS

Yes

No

Auth

IAM

Cassandra roles

IAM

Pick Keyspaces when you already have Cassandra application code and the unsupported list does not touch you, or when you want a wide-column model with zero cluster operations and your team already thinks in CQL. Pick DynamoDB when you are starting from scratch with no Cassandra code to preserve: it is the more native AWS database and the comparison is not close for greenfield work. Pick self-managed Cassandra (or another managed Cassandra) when you need secondary indexes, materialized views, UDFs, aggregate functions, or QUORUM and serial semantics.

The honest test is to read the unsupported list below against your actual schema and queries before you commit to anything.

What it costs

I could not retrieve per-unit dollar rates from the pricing pages in the automated research for this article, and I will not quote numbers I did not see. Open the Keyspaces pricing page for your Region and use the Pricing Calculator. What the pricing page does state precisely is how usage is metered, and that is the part that surprises people.

Writes are billed in 1 KB units per row. A 3 KB row costs 3 write request units (on-demand) or 3 write capacity units (provisioned). Reads are billed in 4 KB units, rounded up per request: a 10 KB read costs 3 read units at LOCAL_QUORUM, and LOCAL_ONE reads cost half. Multi-row reads are billed on aggregate size. All writes use LOCAL_QUORUM.

The surprises: a wide row written often is expensive because you pay per KB, not per row; static-column writes mixed with regular columns need additional write operations; TTL deletes are metered (one TTL delete per KB of the row deleted or updated); point-in-time recovery is charged on the size of every PITR-enabled table until you disable it; restores are charged on the data restored; and multi-Region writes are billed in every Region, so a 3 KB write replicated to two Regions costs 6 units.

Storage is billed on table size, continuously monitored. There is a free tier for the first three months: 30 million on-demand write units, 30 million on-demand read units and 1 GB of storage per month, one per payer account. Data transfer between Keyspaces and other AWS services in the same Region is $0.00 per GB. VPC endpoint charges are separate.

Following along with this article, you write a few thousand tiny rows, run a handful of reads, and enable PITR on one small table for under an hour. That sits comfortably inside the free tier on a new account. Outside the free tier, it is a small number of cents; check the rates yourself before you run it on a production account.

The limits that bite

From the quotas page: 256 keyspaces and 256 tables per Region by default (both adjustable). Maximum row size is 1 MB, excluding static data, and not adjustable. Static data is capped at 1 MB per logical partition. Partition key up to 2048 bytes, clustering key up to 850 bytes combined. Table schema up to 350 KB. A maximum of 50 concurrent DDL operations per Region.

Throughput: 40,000 read and 40,000 write units per second per table per Region by default, adjustable, with account-level provisioned limits of 80,000 each per Region. The limit that hits first in practice is the one per connection: 3,000 CQL queries per second per TCP connection, with no limit on the number of connections. Keyspaces exposes nine peer addresses, so a driver with default settings (one connection per peer) reaches 27,000 queries per second. The docs recommend planning for 500 per second per connection, so 18,000 queries per second means about 36 connections. VPC endpoints may expose fewer peers, so raise the connections per host in your driver.

Provisioned capacity decreases are limited to four per day (UTC) plus one more for each hour without a decrease, 27 at most. Increases are unlimited up to your quota.

Behavioral limits: results paginate automatically after 1 MB of data is read, and pages are based on rows read, not rows returned, so a filtered query can return short or even empty pages while more data remains. Always loop until the driver says there is no next page. Range deletes remove up to 1,000 rows per operation, are not isolated, and per the docs become asynchronous starting July 1, 2026: a success response means the request was accepted, and you confirm completion by querying the range until it is empty. IN is supported only in SELECT, results come back in statement order rather than lexicographic order, and each IN subquery counts toward the per-connection query limit (up to 100 subqueries by default).

The unsupported surface

The "Cassandra API support" table marks these statements as not supported: CREATE INDEX, DROP INDEX, ALTER TYPE, CREATE and DROP TRIGGER, CREATE and DROP FUNCTION, CREATE and DROP AGGREGATE, CREATE, ALTER and DROP MATERIALIZED VIEW, and TRUNCATE. Aggregate functions and user-defined functions are marked no. Supported: keyspace, table and type DDL (except ALTER TYPE), logged and unlogged batches, SELECT, INSERT, UPDATE, DELETE, JSON for SELECT and INSERT, static columns, counters, TTL, UDTs, and the datetime, time conversion, timeuuid, token, uuid, cast and blob conversion functions.

Notice what is missing: no secondary indexes means every lookup that is not by primary key needs its own table. No TRUNCATE means emptying a table is a delete-and-recreate or a range-delete job. No aggregates means COUNT and friends are your problem.

Build it

We will create a keyspace and a table through the control-plane API, wait for it to become ACTIVE, connect with a Cassandra driver using SigV4, load rows, and then run a probe script that deliberately issues unsupported statements and unsupported consistency levels so you see the failures with your own eyes. I have not executed this code against a live account while writing; it follows the AWS SDK code examples and the Python driver page cited in Sources, so run it in a sandbox account first.

Prerequisites

Python 3.9 or newer, an AWS account with credentials in your environment, and these packages:

pip install boto3 cassandra-driver cassandra-sigv4

Build the TLS bundle. The docs say to trust the Amazon Root CAs 1 to 4 plus the Starfield root during the certificate transition:

curl -O https://www.amazontrust.com/repository/AmazonRootCA1.pem
curl -O https://www.amazontrust.com/repository/AmazonRootCA2.pem
curl -O https://www.amazontrust.com/repository/AmazonRootCA3.pem
curl -O https://www.amazontrust.com/repository/AmazonRootCA4.pem
curl -O https://certs.secureserver.net/repository/sf-class2-root.crt
cat AmazonRootCA1.pem AmazonRootCA2.pem AmazonRootCA3.pem AmazonRootCA4.pem sf-class2-root.crt > keyspaces-bundle.pem

IAM

Use a sandbox role. The IAM actions use the cassandra: prefix, and the resource ARNs are arn:aws:cassandra:REGION:ACCOUNT:/keyspace/NAME/ for a keyspace (note the trailing slash) and arn:aws:cassandra:REGION:ACCOUNT:/keyspace/NAME/table/TABLE for a table. Two details from the IAM page matter. CREATE KEYSPACE maps to cassandra:Create and cannot target a specific existing resource, so it needs the wildcard resource (a single asterisk character). And standard Cassandra drivers read system tables during connection initialization, so your policy needs cassandra:Select on the keyspace ARN that ends in "system" followed by an asterisk (arn:aws:cassandra:REGION:ACCOUNT:/keyspace/system and then the asterisk), or the driver will fail before your first query. For this tutorial, a throwaway role that allows every cassandra action on your lab keyspace, on the system keyspaces and on the create-keyspace wildcard resource is simplest. Do not ship that to production; confirm the exact least-privilege action names for each statement in the Service Authorization Reference.

Step 1: create the keyspace and table

import time
import boto3
from botocore.exceptions import ClientError

REGION = "us-east-1"
KS = "sl_lab"
TABLE = "readings"

ks = boto3.client("keyspaces", region_name=REGION)

def wait_keyspace(name, tries=40):
    for _ in range(tries):
        try:
            ks.get_keyspace(keyspaceName=name)
            return
        except ClientError as e:
            if e.response["Error"]["Code"] != "ResourceNotFoundException":
                raise
        time.sleep(3)
    raise TimeoutError("keyspace not ready")

def wait_table(name, tries=60):
    for _ in range(tries):
        t = ks.get_table(keyspaceName=KS, tableName=name)
        if t["status"] == "ACTIVE":
            return t
        time.sleep(5)
    raise TimeoutError("table not ACTIVE")

ks.create_keyspace(keyspaceName=KS)
wait_keyspace(KS)

ks.create_table(
    keyspaceName=KS,
    tableName=TABLE,
    schemaDefinition={
        "allColumns": [
            {"name": "device_id", "type": "text"},
            {"name": "ts", "type": "timestamp"},
            {"name": "temp_c", "type": "double"},
        ],
        "partitionKeys": [{"name": "device_id"}],
        "clusteringKeys": [{"name": "ts", "orderBy": "DESC"}],
    },
    capacitySpecification={"throughputMode": "PAY_PER_REQUEST"},
    pointInTimeRecovery={"status": "ENABLED"},
)
t = wait_table(TABLE)
print(t["status"], t["capacitySpecificationSummary"])

The calls and the table-status polling mirror the AWS code examples. The capacitySpecification and clusteringKeys shapes follow the CreateTable API reference; if boto3 rejects a field name, check your boto3 version against the API reference rather than guessing. PAY_PER_REQUEST is the on-demand mode. PITR is enabled here so you can see its cost line item; turn it off in cleanup.

Step 2: connect and load rows

import random
from datetime import datetime, timedelta, timezone
from ssl import SSLContext, PROTOCOL_TLSv1_2, CERT_REQUIRED

import boto3
from cassandra import ConsistencyLevel
from cassandra.cluster import Cluster, ExecutionProfile, EXEC_PROFILE_DEFAULT
from cassandra.policies import DCAwareRoundRobinPolicy
from cassandra_sigv4.auth import SigV4AuthProvider

REGION = "us-east-1"
ssl_ctx = SSLContext(PROTOCOL_TLSv1_2)
ssl_ctx.load_verify_locations("keyspaces-bundle.pem")
ssl_ctx.verify_mode = CERT_REQUIRED

auth = SigV4AuthProvider(boto3.Session(region_name=REGION))
profile = ExecutionProfile(
    consistency_level=ConsistencyLevel.LOCAL_QUORUM,
    load_balancing_policy=DCAwareRoundRobinPolicy(),
)
cluster = Cluster(
    [f"cassandra.{REGION}.amazonaws.com"],
    ssl_context=ssl_ctx,
    auth_provider=auth,
    port=9142,
    execution_profiles={EXEC_PROFILE_DEFAULT: profile},
    protocol_version=4,
)
session = cluster.connect("sl_lab")

ins = session.prepare(
    "INSERT INTO readings (device_id, ts, temp_c) VALUES (?, ?, ?)"
)
now = datetime.now(timezone.utc)
for d in range(5):
    for i in range(200):
        session.execute(
            ins,
            [f"dev-{d}", now - timedelta(minutes=i), 18 + random.random() * 10],
        )
print("loaded 1000 rows")

The connection setup (TLS context, SigV4 auth provider, port 9142, LOCAL_QUORUM profile, DC-aware round robin, protocol version 4) follows the AWS Python driver page and the AWS code-library scenario. One row per statement is deliberate: each insert here is a small write and a single request unit, which makes the metering easy to reason about.

For a real migration of an existing Cassandra table you do not loop in Python. The docs describe cqlsh COPY FROM for smaller datasets and several other tools; start from the "How to select the right tool for bulk uploading or migrating data" page. The cqlsh tuning guidance in the docs is concrete: set INGESTRATE to about 90 percent of the table's provisioned write capacity, NUMPROCESSES to your core count minus one, MAXBATCHSIZE to 30 or less, and CHUNKSIZE to (INGESTRATE divided by NUMPROCESSES) divided by MAXBATCHSIZE. Over-driving a table's write capacity during a load is how you get throttled.

Step 3: verify the read path

rows = session.execute(
    "SELECT device_id, ts, temp_c FROM readings WHERE device_id = 'dev-0' LIMIT 5"
)
for r in rows:
    print(r.device_id, r.ts, round(r.temp_c, 2))

count = 0
for _ in session.execute("SELECT device_id FROM readings WHERE device_id = 'dev-1'"):
    count += 1
print("dev-1 rows:", count)  # expect 200

Expect five rows ordered newest first, and 200 for dev-1. Iterating the result set lets the driver follow pages, which matters because Keyspaces paginates after 1 MB read.

Step 4: the probe script

This is the deliverable of the article. It runs each unsupported feature and prints exactly what your driver reports, so you learn your compatibility surface from your own account, not from a blog post.

from cassandra import ConsistencyLevel
from cassandra.query import SimpleStatement

def probe(label, cql, cl=None):
    stmt = SimpleStatement(cql)
    if cl is not None:
        stmt.consistency_level = cl
    try:
        session.execute(stmt)
        print(f"OK    {label}")
    except Exception as e:
        print(f"FAIL  {label}: {type(e).__name__}: {str(e)[:120]}")

probe("secondary index",
      "CREATE INDEX ON readings (temp_c)")
probe("materialized view",
      "CREATE MATERIALIZED VIEW sl_lab.hot AS SELECT * FROM readings "
      "WHERE temp_c IS NOT NULL AND device_id IS NOT NULL AND ts IS NOT NULL "
      "PRIMARY KEY (temp_c, device_id, ts)")
probe("truncate", "TRUNCATE readings")
probe("aggregate", "SELECT COUNT(*) FROM readings WHERE device_id = 'dev-0'")

for name in ["ONE", "LOCAL_ONE", "LOCAL_QUORUM", "QUORUM", "ALL", "SERIAL"]:
    probe(f"read at {name}",
          "SELECT * FROM readings WHERE device_id = 'dev-0' LIMIT 1",
          getattr(ConsistencyLevel, name))

probe("LWT insert",
      "INSERT INTO readings (device_id, ts, temp_c) "
      "VALUES ('lwt', '2026-01-01', 1.0) IF NOT EXISTS")

Per the docs, expect failures for the index, the materialized view and TRUNCATE; expect QUORUM, ALL and SERIAL reads to fail; expect ONE, LOCAL_ONE and LOCAL_QUORUM to pass; and expect the LWT insert to succeed. The COUNT(*) line is there because the docs list aggregate functions as unsupported, but I did not verify how Keyspaces treats COUNT specifically, so treat that line as a question your own run answers. The exception types and messages are whatever your driver version produces; the docs do not name them.

Optional: run a read at LOCAL_ONE versus LOCAL_QUORUM against the same rows and compare your consumed read units in CloudWatch or the console. The half-price LOCAL_ONE read is the cheapest optimization Keyspaces offers, and the cost is that you may read a stale replica.

When it breaks

The driver fails before your first query. Almost always the TLS bundle (you trusted only one root and the Region now serves the other) or the missing cassandra:Select on the system keyspaces. Fix the bundle first, the policy second.

Your CREATE TABLE returned but inserts fail. The table is still being created. DDL is asynchronous; poll get_table until the status is ACTIVE, as Step 1 does. The same applies to ALTER TABLE: the status is not ACTIVE until the change finishes.

A statement works on your Cassandra cluster and errors here. Check it against the unsupported list above before debugging anything else. Index creation, views, UDFs, aggregates, TRUNCATE and ALTER TYPE are not coming.

A QUORUM read errors. Reads support ONE, LOCAL_ONE and LOCAL_QUORUM only. Change the driver default to LOCAL_QUORUM rather than patching call sites.

Throughput stalls at a suspiciously round number. That is the 3,000 queries per second per connection limit. Add connections per host in the driver. Remember each IN subquery counts as a query.

A filtered query returns an empty page and you think the data is gone. Pages are based on rows read, so a filtered page can be short or empty. Keep fetching until there is no next page.

A range delete reports success but rows still exist. Since July 1, 2026 range deletes are asynchronous per the docs: query the range until it comes back empty.

A migration load gets throttled. You drove ingest above the table's write capacity. Apply the INGESTRATE formula above, or run the load against an on-demand table and compare.

Cleanup

Delete the table, wait for it to disappear, then delete the keyspace. If you restored a PITR copy during experiments, delete that too, since PITR and restored tables are billed by size until removed.

import time, boto3
from botocore.exceptions import ClientError

ks = boto3.client("keyspaces", region_name="us-east-1")
ks.delete_table(keyspaceName="sl_lab", tableName="readings")
while True:
    try:
        ks.get_table(keyspaceName="sl_lab", tableName="readings")
        time.sleep(5)
    except ClientError as e:
        if e.response["Error"]["Code"] == "ResourceNotFoundException":
            break
        raise
ks.delete_keyspace(keyspaceName="sl_lab")

for k in ks.list_keyspaces()["keyspaces"]:
    print(k["keyspaceName"])  # sl_lab must not be listed

Confirm in the console that no tables remain in sl_lab, then delete the local keyspaces-bundle.pem and the sandbox IAM role. On-demand tables with no traffic and no data cost nothing for requests, and deleting the table ends the storage and PITR charges.

The decision in one sentence

Keyspaces is the right answer when your Cassandra code lives inside the supported surface and you want the cluster gone; run the probe script against your real schema before you plan the migration, because the unsupported list is the whole project.

Sources