CQL: the key that decides everything
After this lesson you will read a PRIMARY KEY line the way Cassandra does — which columns choose the node, which ones sort the rows — predict which SELECT, UPDATE and DELETE it accepts, and write the CQL of every Cassandra exercise of the past papers.
The exercise session asks for a table of cars "sorted by max_speed in ascending order" and answers with the key PRIMARY KEY (car_id, max_speed) and the option WITH CLUSTERING ORDER BY (max_speed ASC) (p. 1607). Cassandra accepts the table without a word.
Load nine cars and ask for all of them with SELECT * FROM car;. One run printed the speeds 340, 190, 180, 160, 320, 160, 296, 180, 170. Ask for the promised order, SELECT * FROM car ORDER BY max_speed;, and Cassandra refuses: "ORDER BY is only supported when the partition key is restricted by an EQ or an IN."
Nothing is broken: the table does exactly what its PRIMARY KEY line says. Read that line as Cassandra reads it, and section 6 repairs the table by moving one column.
Cassandra in one slide, and a keyspace to work in
The session opens with a definition (p. 1548): "Apache Cassandra is a highly scalable, high-performance non-relational, distributed database designed to handle large amounts of data, providing high availability with no single point of failure." The feature list (p. 1549) adds "Highly and Linearly Scalable", "Quick Response Time", "Flexible Data Storage", "Easy Data Distribution", "BASE Properties" and "Fast Writes".
Behind each claim is a mechanism. No master: every node can serve any request and every row is copied to several nodes, so one dead machine stops nothing. A new node takes over its share of the rows, so capacity grows with the nodes. A write is appended to memory and a log without reading first, so writes are fast. How the nodes share the rows is lesson 22; this lesson is the language you talk to them in. The session sums up the theory in two tables worth knowing word for word:
| ACID (RDBMS) | BASE (Cassandra) | |
|---|---|---|
| Atomicity | Transactions are all-or-nothing | Atomicity not guaranteed; partial updates possible |
| Consistency | Strict rules, always valid state | Eventual consistency, tunable per query |
| Isolation | Transactions don't interfere | Concurrent writes may cause temporary conflicts |
| Durability | Data persists once committed | Durable, but replicas may lag |
| Availability | May sacrifice availability for consistency | Always available, even if inconsistent |
| State | Strong, predictable | Soft, may change asynchronously |
| Feature | Relational Databases (RDBMS) | Cassandra |
|---|---|---|
| Data Model | Tables with rows (records) and columns (attributes). Normalized schema with relationships (foreign keys, joins). | Keyspaces (like databases) contain tables (column families). Tables are wide-column stores with flexible schemas. |
| Schema | Rigid, predefined schema. Each row must follow the same structure. | Schema is flexible. Columns can vary by row; supports sparse data. |
| Relationships | Strong support for joins, foreign keys, normalization. | No joins or foreign keys. Data is denormalized and duplicated for query efficiency. |
| Query Language | SQL (Structured Query Language). Rich support for joins, aggregations, transactions. | CQL (Cassandra Query Language). Similar to SQL syntax but limited (no joins, limited aggregations). |
| Transactions | ACID-compliant (Atomicity, Consistency, Isolation, Durability). | Tunable consistency (choose between strong and eventual consistency). Focus on availability and partition tolerance. |
| Scalability | Vertical scaling (adding more power to a single server). Horizontal scaling is harder. | Horizontal scaling by adding nodes. Designed for distributed environments with no single point of failure. |
| Performance | Optimized for complex queries and consistency. | Optimized for high write throughput and fast reads across distributed nodes. |
Is a Cassandra write really "partial"?
Not inside one partition. Background from the Cassandra documentation, not from the slides: an INSERT, UPDATE or DELETE that touches one partition is applied as a whole, and a logged BATCH ends up either fully applied or not at all. What Cassandra gives up is the bank transaction: several partitions changed in isolation. Answer with the table as printed, and add this when the question is open. The CAP side of the story is lesson 5.
The vocabulary nests like a filing cabinet. A keyspace is "the outermost container in Cassandra" (p. 1553), roughly a database. It holds tables, the lecture's column families: the keyword TABLE "can be interchanged with COLUMNFAMILY" (p. 1563). Tables hold rows, rows hold columns; lesson 20 draws the picture, and from here on everything is CQL typed at the cqlsh prompt.
-- p. 1553: a keyspace that keeps three copies of every row
CREATE KEYSPACE population
WITH replication = {'class': 'SimpleStrategy',
'replication_factor': 3};
Read it aloud: create a container called population; replicate every row with the simple strategy, three copies. The replication map, strategy plus factor, is the one property a keyspace must declare. On a one-node laptop the statement still runs, with a warning: "Your replication factor 3 for keyspace population is higher than the number of nodes 1".
-- p. 1555: the prompt becomes cqlsh:population>
USE population;
-- p. 1555: list the keyspaces (DESCRIBE tables; and
-- DESCRIBE person; do the same for tables, pp. 1558-1559)
DESCRIBE keyspaces;
-- p. 1556: change the properties of a keyspace
ALTER KEYSPACE population
WITH replication = {'class': 'SimpleStrategy',
'replication_factor': 2};
-- p. 1556: remove the keyspace and every table in it
DROP KEYSPACE population;
"Type the code as the slide shows it." The slides print typographic quotes, ‘SimpleStrategy’, and several boxes end without a semicolon. cqlsh rejects the curly quotes ("Invalid syntax at line 1, char 48") and waits for more input until it sees ;. Straight single quotes and a final semicolon, always.
PRIMARY KEY: where a row lives, and in which order
The session's first table (p. 1558) describes people:
-- p. 1558: one partition per person, its rows sorted by age
CREATE TABLE person (
personal_id text,
name text,
age varint,
birth_date text,
gender text,
PRIMARY KEY (personal_id, age)
);
Column by column it is SQL; the last line is not. The columns inside PRIMARY KEY "have different meaning depending on the order and the brackets" (p. 1560), and the slide gives both meanings:
Partition key — "The first value (or set of values) is referred to as the Partition Key(s). It defines how the data is partitioned within the Cassandra nodes." Here: personal_id.
Clustering key — "The second value (or set of values) is named Clustering Key(s). It is used to define the way data is stored within a partition (i.e., the sorting)." Here: age.
The mechanism: Cassandra hashes the partition-key value, and the number that comes out, the token, names the node that stores the row. Rows with the same partition-key value get the same token, so they sit together in one partition on one node, kept sorted by the clustering key. Think of an archive: the hash of the surname picks the building, and inside it the folders stand in date order.
| PRIMARY KEY … | Partition key: which node | Clustering key: order inside |
|---|---|---|
(a) | a | none: one row per partition |
(a, b, c) | a | b, then c |
((a, b), c) | a and b together: a composite partition key | c |
((a, b), c, d) | a and b together | c, then d |
A composite partition key is one value: (Fiat, 2024) and (Fiat, 2023) are two partitions, possibly on two nodes. And the full primary key, partition plus clustering columns, identifies one row: two rows with the same full key are the same row — the trap of section 4.
Inside a partition the order is ascending unless the table says otherwise (p. 1554):
-- p. 1554's option (printed ASC), here DESC:
-- in each partition, the oldest first
CREATE TABLE person_by_age_desc (
personal_id text,
age varint,
name text,
PRIMARY KEY (personal_id, age)
) WITH CLUSTERING ORDER BY (age DESC);
The papers state the key in words:
| The paper says | You write |
|---|---|
| "Personal_ID as the partition key and Policeman_ID as the clustering key" (6 Jun 2024) | PRIMARY KEY (personal_id, policeman_id) |
| "… ordering the table based on the Phone Number" (6 Sep 2024) | the clustering key, plus WITH CLUSTERING ORDER BY (phone_number ASC) |
| "Favourite_Team as a clustering key and Name as an additional partition key" (11 Feb 2025) | PRIMARY KEY ((personal_id, name), favourite_team) |
| "query Heaters simultaneously by Floor number and Department specialty, sorting the results by total heating power" (30 Jan 2026) | PRIMARY KEY ((floor_number, specialty), heating_power, heater_sys_id) |
Inside the inner brackets: where the row lives. After them: the order the rows keep there. All of it together: which row it is.
Changing a table, and what cannot change
The other table statements work as in SQL (pp. 1561–1563), and each accepts COLUMNFAMILY in place of TABLE. The session adds the address that its INSERT fills in section 4:
-- p. 1562: a new column; the existing rows have no value in it
ALTER TABLE person ADD address text;
-- p. 1562: salary, added the same way, is dropped again
ALTER TABLE person DROP salary;
-- p. 1563: remove every row, keep the table
TRUNCATE TABLE person;
-- p. 1563: remove the table itself
DROP TABLE person;
The key is the one thing ALTER cannot touch: ALTER TABLE person DROP age; answers "Cannot drop PRIMARY KEY column age", so a wrong key means a new table and a copy of the data. And this is what "Schema is flexible" (p. 1551) means in CQL: every table declares its columns, a row may leave any non-key column empty at no cost, and ALTER TABLE adds a column at any time.
Reading: what a WHERE clause may say
The query Cassandra is built for names the partition (p. 1567):
-- one hash, one node, one partition: Francesco's rows
SELECT * FROM person WHERE personal_id = 'FRNTRZ95E12F675T';
Cassandra hashes the value, goes to the node that owns the token and reads one partition. A WHERE clause that does not name the partition is a question one hash cannot answer, and the session tries one at once (p. 1568), "Let's retrieve people by gender":
-- refused: gender is neither a key column nor indexed
SELECT * FROM person WHERE gender = 'Male';
cqlsh answers: "Cannot execute this query as it might involve data filtering and thus may have unpredictable performance. If you want to execute this query despite the performance unpredictability, use ALLOW FILTERING". An exact description: to find the men, Cassandra would read every row on every node and throw most of them away.
The next slide (p. 1569) lists the rules. In order, each with its reason:
- Name every column of the partition key, with
=orIN: "WHERE clauses must include ALL components of the partition key". Half a composite key gives no token. - Restrict clustering columns as a prefix, in their order; only the last may be a range. "To filter on a later clustering key, all preceding clustering key(s) must be specified". The rows are sorted by the clustering columns, so such a prefix is one contiguous slice; anything else means hopping. Skip one and cqlsh says, for example, "PRIMARY KEY column "max_speed" cannot be restricted as preceding column "year" is not restricted".
- No OR. "Clustering Keys constraints producing non-contiguous results will fail": a range with a hole in it cannot be asked for. Conditions are joined by AND only; the red pen below explains why the slide's own example fails.
- Any other column needs a secondary index, and then only
=. "To include additional fields in the query, it is necessary to create a secondary index." A range on an indexed column,gender > 'A', still needs ALLOW FILTERING. - ALLOW FILTERING switches the rules off, "although it is not recommended due to bad execution-time performance": Cassandra reads and discards, so the cost grows with the table, not with the answer.
A SELECT with no WHERE, SELECT * FROM car;, needs no ALLOW FILTERING: it reads everything but discards nothing. The index of rule 4 is created as on p. 1564, and the car exercise uses it so (p. 1611):
-- p. 1611: an index on a column that is not part of the key
CREATE INDEX car_price ON car (price);
-- p. 1611: now legal, without partition key or ALLOW FILTERING
SELECT * FROM car WHERE price = 35000;
-- p. 1610: a range on that column: only ALLOW FILTERING helps
SELECT * FROM car WHERE price > 100000 ALLOW FILTERING;
-- p. 1565: remove an index you no longer need
DROP INDEX person_address;
The slide calls indexes "one of the most important elements of a table". Why does the lecture prefer an "index factory"?
Background from the Cassandra documentation: a secondary index is local. Every node indexes only its own rows, so a query by the indexed column alone asks every node. Fine for a small table, and exactly the exam's "further operation". On a large cluster the lecture's answer scales better: "cassandra is an index factory" (p. 431, lesson 20) — a second table whose partition key is the searched column, so the query is one hash again (section 5).
"If any further operation is required, write its CQL code" asks exactly this: is the WHERE column part of the key? If not, the further operation is CREATE INDEX for an equality; for a range only ALLOW FILTERING works — write it, and say it is not recommended.
Writing: every write is an upsert
The session inserts a person (p. 1566):
-- p. 1566: Francesco, aged 26
INSERT INTO person (personal_id, address, age,
birth_date, gender, name)
VALUES ('FRNTRZ95E12F675T', 'Via Milano 12', 26,
'12-05-1995', 'Male', 'Francesco Terzani');
Columns and values match in order; the primary-key columns must be there, the others may be left out. The template's USING <option> is not explained on the slide: it sets a time to live, USING TTL 86400 expires the values after a day, or a write timestamp (Cassandra documentation).
Now the question no slide asks: what if a row with that primary key already exists? In SQL, a duplicate-key error. In Cassandra, nothing: the INSERT overwrites the columns it lists and keeps the others. An INSERT is an upsert — update or insert, decided by the primary key alone. Cassandra never reads before writing, part of why writes are fast; to refuse duplicates you ask for it, INSERT … IF NOT EXISTS, a lightweight transaction (not in the slides).
UPDATE follows the same logic (p. 1570): "The WHERE clause must include all components of the primary key (partition key and clustering keys). You cannot update or change primary key values." Leave out age and cqlsh answers "Some clustering keys are missing: age"; try SET age = 27 and it answers "PRIMARY KEY part age found in SET part". When no row has the key, the UPDATE creates one.
-- p. 1570 as printed: "Let's update Francesco's address"
UPDATE person
SET address = 'Via Milani 13'
WHERE personal_id = 'FRNTRZ95E12F675T' AND age = 32;
DELETE needs less (p. 1571): "You must specify the full partition key in the WHERE clause of a DELETE. Instead, Clustering Key(s) are not strictly required." The width of the WHERE decides how much goes:
-- p. 1571: the whole partition, every row of Francesco
DELETE FROM person WHERE personal_id = 'FRNTRZ95E12F675T';
-- one row of the partition
DELETE FROM person
WHERE personal_id = 'FRNTRZ95E12F675T' AND age = 26;
-- one column of one row: the address becomes null
DELETE address FROM person
WHERE personal_id = 'FRNTRZ95E12F675T' AND age = 26;
A delete is itself a write: a marker, a tombstone, hides the old values until a later cleanup (lesson 22). Several writes can also travel together (p. 1572):
-- the template of p. 1572, filled in:
-- one request, one timestamp for every statement
BEGIN BATCH
INSERT INTO person (personal_id, age, name)
VALUES ('RSSMRA80A01F205X', 45, 'Maria Rossi');
UPDATE person SET address = 'Via Roma 1'
WHERE personal_id = 'RSSMRA80A01F205X' AND age = 45;
DELETE FROM person
WHERE personal_id = 'FRNTRZ95E12F675T';
APPLY BATCH;
The batch earns its place elsewhere: when one fact is stored in two tables, a logged batch (the default) writes both copies or neither — background from the Cassandra documentation, and the glue of section 5.
Types, scripts, and the JOIN that is not there
Types first (p. 1577): "text, varint, float, double, Boolean, etc.", plus int, date, timestamp and uuid, filled by the function uuid() (p. 1609). Two kinds are special. A collection keeps several values in one column: list<T> (ordered, duplicates allowed), set<T> (unique values), map<K, V>. The slide shows the list:
-- p. 1577: a column that holds a list of e-mails
CREATE TABLE test (id int PRIMARY KEY, email list<text>);
-- p. 1577: append to the list; a set takes {'x'}, a map {'k': 1.5}
UPDATE test SET email = email + ['a@polimi.it'] WHERE id = 1;
A user-defined type is a named record used as a column type; "it is necessary to define the data type before using it" (p. 1578). The car exercise defines a feature (p. 1608):
-- p. 1608: a feature has a name and a description
CREATE TYPE feature (
name text,
description text
);
-- p. 1608: a list of features, the type frozen
ALTER TABLE car ADD features list<frozen<feature>>;
"A frozen data type can only be overwritten; it can't be edited anymore" (p. 1608): the value is stored as one block. DESCRIBE TYPE feature; checks it; ALTER TYPE and DROP TYPE change or remove it (p. 1578). Three commands of the cqlsh console, not CQL statements, complete the toolkit (pp. 1573–1575):
-- p. 1575 (path quoted): run every statement of a file, as if typed
SOURCE 'D:/Program Files/Cassandra/Queries/query_1.txt';
-- p. 1573 (path quoted): query results go to the file until CAPTURE off;
CAPTURE 'D:/Program Files/Cassandra/Outputs/output.txt';
-- p. 1574: one line per column, for wide rows; EXPAND off; stops it
EXPAND on;
Last, the slide everybody expects, "FOREIGN KEY (?)" (p. 1576): "In Cassandra there is no concept of FOREIGN KEYS and/or relationships, if you want any cross-table check to be performed, you have to manage it by yourself." The reason is again the key: two tables place their rows by two different partition keys, on different nodes, so a join would be a conversation between machines for every row. Instead, p. 1551: "Data is denormalized and duplicated for query efficiency", and the lecture's method (lesson 20): "Start from queries, then design the data model" (p. 430); "cassandra is an index factory" (p. 431). The puzzles paper of 30 May 2025 searches purchases by puzzle and by the buyer's city or country — two tables, every purchase written into both:
state:city (p. 432) is the same idea in the older notation.One table per query: its equality conditions become the partition key, its order the clustering key, and every column it returns is copied in.
The car dealership, end to end
The exercise session (pp. 1606–1612) is a complete exam script on one table. Keyspace and table first:
-- p. 1606: the keyspace of the exercise
CREATE KEYSPACE car_dealer
WITH replication = {'class': 'SimpleStrategy',
'replication_factor': 3};
-- p. 1607: "sorted by max_speed in ascending order"
CREATE TABLE car (
car_id uuid,
brand text,
max_speed varint,
price float,
consumption_lt_per_km float,
PRIMARY KEY (car_id, max_speed)
) WITH CLUSTERING ORDER BY (max_speed ASC);
DESCRIBE car_dealer; checks the keyspace (p. 1606). After USE car_dealer; and the feature column of section 5 — added and dropped again, "As we do not want to deal with complex fields" (p. 1609) — the session inserts a car with a generated key:
-- p. 1609: uuid() generates the unique car_id
INSERT INTO car (car_id, brand, max_speed, price, consumption_lt_per_km)
VALUES (uuid(), 'Ferrari', 320, 200000.00, 30.00);
The rest of the data comes from a file, SOURCE '<path_to_your_folder>/car_data.txt'; (p. 1610). The deck does not show the file; the runs quoted here used nine cars, three Ferraris, three Fiats and three Lancias — the nine of the widget. The queries of pp. 1610–1612 then give:
-- p. 1610: "cost more than 100'000": a range, so ALLOW FILTERING (3 Ferraris)
SELECT * FROM car WHERE price > 100000 ALLOW FILTERING;
-- p. 1611: "exactly 35'000 (without using ALLOW FILTERING)": an index first
CREATE INDEX car_price ON car (price);
-- p. 1611: then the equality (a Fiat and a Lancia)
SELECT * FROM car WHERE price = 35000;
-- p. 1611: the sum of all prices: 805000, printed 8.05e+05 for a float
SELECT SUM(price) FROM car;
-- p. 1612: count the Ferraris and store the result in ferrari_count.txt
CAPTURE '<path_to_folder>/ferrari_count.txt';
CREATE INDEX car_brand ON car (brand);
SELECT COUNT(*) FROM car WHERE brand = 'Ferrari';
CAPTURE off;
Neither aggregate names a partition, so both ask every node, and cqlsh adds the warning "Aggregation query used without partition key". The file receives the count, 3, and that warning; while the capture is on, the screen only says "Now capturing query output to …".
Back to the hook
Read the key of p. 1607 as Cassandra does. car_id is the partition key, so every car is a partition of its own, and max_speed sorts the rows of a partition that holds a single row. SELECT * FROM car; walks the partitions in token order — the order of the hashes, hence 340, 190, 180 — and ORDER BY is refused because it only sorts inside partitions that the WHERE clause names. The repair moves one column into the partition key:
Before: (car_id, max_speed)
After: (brand, max_speed, car_id)
SELECT * FROM car; printed them, nine one-row partitions in token order. After: three partitions, each sorted by speed; the Ferrari query (dashed) reads one of them, 296, 320, 340.-- the cars of each brand, sorted by speed: brand decides the node
CREATE TABLE car_by_brand (
brand text,
max_speed varint,
car_id uuid,
price float,
PRIMARY KEY (brand, max_speed, car_id)
) WITH CLUSTERING ORDER BY (max_speed ASC);
-- 296, 320, 340: one partition, already in order
SELECT * FROM car_by_brand WHERE brand = 'Ferrari';
car_id stays at the end of the key so that it still identifies a car: without it, two Fiats with the same top speed would be one row. With the partition named, ORDER BY works as well: ORDER BY max_speed DESC gives 340, 320, 296. The exam keeps using the session's pattern — "Personal_ID as the partition key and Phone Number as the clustering key, ordering the table based on the Phone Number" (6 Sep 2024) — and there the order is real: a chef with two phone numbers is one partition of two sorted rows.
The past papers: six exercises, five patterns
| Paper | What is asked |
|---|---|
| 6 Jun 2024, ex. 5 (3 PT) | table with given keys; query on a non-key column with "further operation"; script file and SOURCE |
| 6 Sep 2024, ex. 5 (4 PT) | table with given keys and order; query with "further operation"; custom data type |
| 11 Feb 2025 (4 PT) | composite partition key; query with extra statements "only if it's strictly necessary"; SOURCE from a path |
| 30 May 2025, ex. 1 (1 PT) | tables for two given queries |
| 12 Sep 2025, ex. 1 (1 PT) | tables for two given queries |
| 30 Jan 2026, ex. 2 (3 PT) | tables for a query, "Do not use indexes"; query with "further operation" |
The first one, worked in full. The ER of 6 Jun 2024 tracks natural disasters: Person (Personal ID, Name, Surname, Postal Code) is the parent of Paramedic, Firefighter and Policeman (Policeman ID, Rank), ISA (P, O). "Consider the Policeman table. Write a Cassandra script to perform the operations listed below." A policeman row carries the inherited Person columns too:
-- "Create the Policeman table with Personal_ID as the partition key and
-- Policeman_ID as the clustering key. (1 PT)"
CREATE TABLE policeman (
personal_id text,
policeman_id text,
name text,
surname text,
postal_code text,
rank text,
PRIMARY KEY (personal_id, policeman_id)
);
-- "collect the Policemen whose Surname is "Rossi". If any further operation
-- is required, write its CQL code. (1 PT)": surname is not a key column
CREATE INDEX policeman_surname ON policeman (surname);
SELECT * FROM policeman WHERE surname = 'Rossi';
-- "the CQL script to be written in an "operations.txt" file to insert two new
-- policemen ... and the CQL operation to run the whole script from the file. (1 PT)"
-- content of operations.txt
INSERT INTO policeman (personal_id, policeman_id, name, surname, postal_code, rank)
VALUES ('RSSMRA80A01F205X', 'PL001', 'Mario', 'Rossi', '20133', 'Sergeant');
INSERT INTO policeman (personal_id, policeman_id, name, surname, postal_code, rank)
VALUES ('VRDLGU85B02F205Y', 'PL002', 'Luigi', 'Verdi', '20121', 'Captain');
-- in cqlsh
SOURCE 'D:/Cassandra/operations.txt';
A full script opens with the keyspace and USE, as on p. 1606. Postal codes are text, not numbers: a code with a leading zero must keep it. Run with these two rows, the query returns Mario Rossi; surname = 'Rossi' ALLOW FILTERING returns him too, and is the alternative to name as "not recommended".
Exam-style questions
Five exercises from the past papers in their printed wording, each after the ER part it uses, with the points the paper prints; then two theory questions. No official solutions exist: every CQL answer below was run on a Cassandra 5.0.9 node with test rows that contain the traps. Write your answer first.
Exam of 6 Sep 2024, exercise 5 (restaurant). ER: Person (Personal_ID, Name, Surname, Birth Date, Phone Number) is the parent of Owner, Customer, Waiter and Chef (Hiring Date); Head Chef (Head Chef Since) is a Chef.
"Consider the Chef table. Write a Cassandra script to perform the operations listed below. Create the Chef table, with Personal_ID as the partition key and Phone Number as the clustering key, ordering the table based on the Phone Number. (1.5 PT) Write a CQL query to collect the chef whose name is "Riccardo". If any further operation is required, write its CQL code. (1 PT) Create a Custom data type named "contact" that contains the name, surname, and phone number of the chef (1.5 PT)"
Show model answer
CREATE KEYSPACE restaurant
WITH replication = {'class': 'SimpleStrategy',
'replication_factor': 3};
USE restaurant;
CREATE TABLE chef (
personal_id text,
phone_number text,
name text,
surname text,
birth_date date,
hiring_date date,
PRIMARY KEY (personal_id, phone_number)
) WITH CLUSTERING ORDER BY (phone_number ASC);
-- name is not part of the key: the further operation is an index
CREATE INDEX chef_name ON chef (name);
SELECT * FROM chef WHERE name = 'Riccardo';
CREATE TYPE contact (
name text,
surname text,
phone_number text
);
-- optional, to use it: a list of contacts of the chef
ALTER TABLE chef ADD contacts list<frozen<contact>>;
Without the index, SELECT * FROM chef WHERE name = 'Riccardo' ALLOW FILTERING; also works, but it scans the table and is not recommended.
The three sub-questions test three things: the bracket form with the clustering order, the index as the "further operation" for a non-key column, and the type definition. Phone numbers are text (a leading + or 0 must survive). On test data, a Riccardo with two phone numbers came back as two rows of one partition, sorted by number: here the clustering order means something.
Exam of 11 Feb 2025, Cassandra exercise (Formula 1). ER: Person (Personal_ID, Name, Surname, Birth_Date, Gender) is the parent of Spectator (Favourite_Team), Team Owner and Team Member, ISA (T, E).
"Consider the spectator entity. Write a CQL statement to create its corresponding Cassandra Table using Favourite_Team as a clustering key and Name as an additional partition key (1 PT)." "Write a CQL statement to return a list of all the spectators whose name is Bill, whose age is over 65, and whose favourite team is "Ferrari". Besides the query, write any additional CQL statements only if it's strictly necessary. (2 PT)" "Write the CQL command to perform a set of statements by loading them from a file located in "D:\Cassandra\Scripts\statement.txt". (1 PT)"
Show model answer
CREATE TABLE spectator (
personal_id text,
name text,
favourite_team text,
surname text,
birth_date date,
gender text,
PRIMARY KEY ((personal_id, name), favourite_team)
);
-- over 65 on the day of the exam (11/02/2025): born before 11/02/1960
SELECT * FROM spectator
WHERE name = 'Bill' AND favourite_team = 'Ferrari'
AND birth_date < '1960-02-11'
ALLOW FILTERING;
SOURCE 'D:\Cassandra\Scripts\statement.txt';
No additional statement is strictly necessary. personal_id is unknown, so the partition key is incomplete, and age is a range on a column outside the key: ALLOW FILTERING is unavoidable. An index on name (allowed on one part of a composite partition key) would narrow the scan, but the query would still need ALLOW FILTERING because of the range.
The key point is "additional partition key": name goes inside the inner brackets next to personal_id. The ER has no age, so it is computed from Birth_Date; state the assumption. Both claims were checked on Cassandra 5.0.9: without ALLOW FILTERING the query is refused, even with an index on name; with it, of five test spectators only the Bill born in 1955 who supports Ferrari comes back. The path may be written with forward slashes too; the quotes are what matters.
Exam of 30 May 2025, exercise 1 (puzzles; the paper's header prints "June 30, 2025"). ER: User (Personal ID, Name, Surname, Age, Birth Date, City, Country) — Buy (Buy Date) — Puzzle (Puzzle ID, Name, Collection Name, Number of Pieces, Width, Height, Description); Puzzle (0, N), User (0, N).
"Suppose you expect to record a large number of purchases of puzzles and you decide to store them in a columnar database. Assume you want to run typical queries by searching by the puzzle IDs and by User City or Country. Write the specification of the tables (and other artifacts if necessary) of a Cassandra deployment that stores the required data."
Show model answer
CREATE KEYSPACE puzzle_sales
WITH replication = {'class': 'SimpleStrategy',
'replication_factor': 3};
USE puzzle_sales;
-- query 1: purchases of a puzzle, newest first
CREATE TABLE purchases_by_puzzle (
puzzle_id text,
buy_date date,
personal_id text,
puzzle_name text,
user_name text,
user_surname text,
city text,
country text,
PRIMARY KEY ((puzzle_id), buy_date, personal_id)
) WITH CLUSTERING ORDER BY (buy_date DESC, personal_id ASC);
-- query 2: purchases by country, or by country and city
CREATE TABLE purchases_by_location (
country text,
city text,
buy_date date,
puzzle_id text,
personal_id text,
puzzle_name text,
user_name text,
user_surname text,
PRIMARY KEY ((country), city, buy_date, puzzle_id, personal_id)
) WITH CLUSTERING ORDER BY (city ASC, buy_date DESC, puzzle_id ASC, personal_id ASC);
-- SELECT * FROM purchases_by_puzzle WHERE puzzle_id = 'PZ01';
-- SELECT * FROM purchases_by_location WHERE country = 'Italy' AND city = 'Milan';
Each purchase is written into both tables, in one BATCH. Cassandra has no joins, so the puzzle and user columns the queries return are copied into each table.
A complete answer shows one table per query, the searched attribute as partition key, a clustering key that keeps rows unique (otherwise two purchases overwrite each other), and denormalised columns. City is the first clustering column, so one table serves both "by country" and "by country and city"; a query by city alone is refused (checked), since a city name is not unique across countries anyway. Separate tables per city and per country are also correct. If one country's partition grows too large, a time bucket can join the partition key, for example ((country, buy_year), …).
Exam of 12 Sep 2025, exercise 1 (farmers and local markets). ER: Product (Product ID, Name, Quantity (in grams), Expiration Date) — Sell — Market (Name and Location, both underlined, Opening Time, Closing Time, Size); Product — Buy (Buy Date, Amount) — People (Personal ID, …); every cardinality (1, N).
"Suppose you expect to record large-scale, continuously increasing number of sales of products in the market, and therefore you decide to store it in a columnar database. Write the specification of the tables (column families) of a Cassandra deployment assuming you may want to query the data by market location or product name."
Show model answer
-- a sale: who bought which product, when and how much (Buy), at which market (Sell)
CREATE TABLE sales_by_market_location (
market_location text,
sale_date date,
sale_id uuid,
market_name text,
product_id text,
product_name text,
personal_id text,
amount int,
PRIMARY KEY ((market_location), sale_date, sale_id)
) WITH CLUSTERING ORDER BY (sale_date DESC, sale_id ASC);
CREATE TABLE sales_by_product_name (
product_name text,
sale_date date,
sale_id uuid,
product_id text,
market_name text,
market_location text,
personal_id text,
amount int,
PRIMARY KEY ((product_name), sale_date, sale_id)
) WITH CLUSTERING ORDER BY (sale_date DESC, sale_id ASC);
-- SELECT * FROM sales_by_market_location WHERE market_location = 'Lodi';
-- SELECT * FROM sales_by_product_name WHERE product_name = 'Pecorino'
-- AND sale_date >= '2025-09-01';
Both tables receive every sale (one BATCH per sale); sale_id, filled with uuid(), keeps two sales of the same day apart.
Same pattern as the puzzles: two queries, two column families, the search attribute as partition key, the date as clustering key so the newest sales come first and a date range is one slice. "Continuously increasing" is the hint about growth: without sale_id sales of one day would overwrite each other, and for a market that never stops selling a month bucket in the partition key, ((market_location, sale_month), …), keeps each partition bounded.
Exam of 30 Jan 2026, exercise 2 (hospital heating). ER: Heating System (Heater_Sys_ID, Heating_Power) is the parent of HVAC System (Hourly_KWh_Cons, Cost_Per_KWh) and Gas Heating System (Hourly_SQM_Cons, Cost_Per_SQM), ISA (T, E); Floor (Floor_ID, Number, Has_Elevator); Department (Department_ID, Specialty, N_Beds, Head_Physician_ID). Heats: Heating System (1:N) – Floor (1:N), with Start_Time, End_Time; Placed Into: Department (1:N) – Floor (1:N).
"Consider the entities Heating System (and its child entities), Floor, and Department. Define the database structure so that one can query Heaters simultaneously by Floor number and Department specialty, sorting the results by total heating power. Write the specification of the tables of a Cassandra deployment that stores the required data. Do not use indexes. (2 PT)" "Write a CQL query to collect all the Gas Heating Systems running for "cardiology" Departments. If any further operation is required, write its CQL code. (1 PT)"
Show model answer
-- one row per heater, for each floor number and specialty it heats;
-- (T, E): one table with a type column, the other type's columns stay empty
CREATE TABLE heaters_by_floor_and_specialty (
floor_number int,
specialty text,
heating_power float,
heater_sys_id text,
heater_type text,
hourly_kwh_cons float,
cost_per_kwh float,
hourly_sqm_cons float,
cost_per_sqm float,
PRIMARY KEY ((floor_number, specialty), heating_power, heater_sys_id)
) WITH CLUSTERING ORDER BY (heating_power DESC, heater_sys_id ASC);
-- SELECT * FROM heaters_by_floor_and_specialty
-- WHERE floor_number = 2 AND specialty = 'cardiology';
-- further operation for the second query: a table keyed by its conditions
CREATE TABLE heaters_by_specialty_and_type (
specialty text,
heater_type text,
heater_sys_id text,
heating_power float,
hourly_sqm_cons float,
cost_per_sqm float,
hourly_kwh_cons float,
cost_per_kwh float,
PRIMARY KEY ((specialty, heater_type), heater_sys_id)
);
SELECT * FROM heaters_by_specialty_and_type
WHERE specialty = 'cardiology' AND heater_type = 'Gas';
"Simultaneously by Floor number and Department specialty" makes both a composite partition key; "sorting by heating power" makes it the first clustering column, DESC assumed (state it); heater_sys_id keeps two heaters of equal power apart. The second query lacks the floor number, so on the first table it would need ALLOW FILTERING over everything (it runs, checked); "Do not use indexes" points to the query-first answer, a second table. The ISA (T, E) becomes a type column in one table, empty columns costing nothing in Cassandra.
Explain the roles of the partition key and of the clustering key of a Cassandra table. Which conditions can the WHERE clause of a SELECT contain without ALLOW FILTERING, and why does Cassandra impose these rules?
Show model answer
The primary key has two parts. The partition key, the first element (or the columns in the inner brackets), is hashed into a token that decides on which nodes the row is stored: all rows with the same partition key form one partition on the same replicas. The clustering key, the remaining columns, decides how the rows are sorted inside the partition (WITH CLUSTERING ORDER BY sets ASC or DESC). Without ALLOW FILTERING, a SELECT that restricts the partition key must restrict every one of its columns with = or IN, so Cassandra can compute the token and go to the right node. Clustering columns may be restricted only as a prefix in their declared order, with a range only on the last one, because then the result is one contiguous slice of the sorted partition; OR does not exist. Any other column needs a secondary index and an equality condition, and such a query may omit the partition key, at the price of asking every node. ALLOW FILTERING lifts the rules, but makes Cassandra read and discard rows, so its cost grows with the size of the table.
A complete answer names both roles, the three legal kinds of condition and the reason behind each: the hash for the partition key, the sorted storage for the clustering prefix, the per-node lookup for the index. Quoting the slide's definitions (p. 1560) is safe.
Which statements about CQL are true? Select all that apply.
Show model answer
True: the second, the fifth and the sixth.
Wrong answers may carry a penalty at the real exam (it is announced just before it), so tick only what you can justify. The fourth option is the SQL reflex; the third is the most common slip about secondary indexes.
Check yourself
A table has PRIMARY KEY ((city, year), month, day). Which WHERE clause runs without ALLOW FILTERING?
The table person has PRIMARY KEY (personal_id, age) and one row ('X', 26) with an address. You run INSERT INTO person (personal_id, age, name) VALUES ('X', 26, 'Ann');. What happens?
The table car has PRIMARY KEY (brand, max_speed, car_id) and a secondary index on price. Which SELECTs run without ALLOW FILTERING? Select all that apply.
All five were run on Cassandra 5.0.9. Equality uses an index, a range never does.
Francesco has two rows in person, ages 26 and 32. What does DELETE FROM person WHERE personal_id = 'FRNTRZ95E12F675T'; remove?
A batch holds DELETE FROM person WHERE personal_id = 'B'; followed by INSERT INTO person (personal_id, age, name) VALUES ('B', 1, 'Bo');. What is left after APPLY BATCH;?
The session's table has PRIMARY KEY (car_id, max_speed), car_id a uuid, and WITH CLUSTERING ORDER BY (max_speed ASC). What does the clustering order change in the output of SELECT * FROM car;?
Which command runs the statements stored in D:\Cassandra\Scripts\statement.txt from cqlsh?
Key take-aways
- Cassandra is a distributed, masterless, BASE database: no single point of failure, linear scaling, fast writes. Know the ACID/BASE table of p. 1550 and the comparison with relational systems of p. 1551.
- In
PRIMARY KEY ((partition key), clustering columns)the partition key is hashed to pick the node, the clustering columns sort the rows inside the partition (WITH CLUSTERING ORDER BY), and the whole key identifies one row. - Without ALLOW FILTERING a WHERE clause either names every partition-key column with = or IN and then a prefix of the clustering columns, with at most one range at its end, or uses = on a column with a secondary index; OR does not exist.
- INSERT and UPDATE are upserts: the key decides the row and nothing is checked, so a wrong clustering value writes a ghost row. UPDATE needs the full primary key and cannot change it; DELETE needs the partition key and removes as much as its WHERE names; a BATCH gives all its statements one timestamp.
- No joins, no foreign keys: denormalise, one table per query, its equalities as partition key and its order as clustering key. Collections and frozen user-defined types keep related values in one row.
- cqlsh commands take quoted paths:
SOURCE 'file';,CAPTURE 'file';…CAPTURE off;, andEXPAND on;for wide rows.
What to watch and read
-
Quickstart
Apache Cassandra
A one-node Cassandra and cqlsh in a few commands (it needs Docker): the place to type this lesson's statements, the session's car script and your exam answers, and to read the real messages.
-
Logical data modeling
Apache Cassandra documentation
The procedure of the design exercises worked on a hotel example: a list of queries becomes tables named
hotels_by_poi, each with its partition and clustering columns. Read it next to sections 5 and 6. -
Working with secondary indexing (2i)
Apache Cassandra documentation
When a secondary index helps and when it hurts, with the exact ALLOW FILTERING message: the background behind rule 4 and the "further operation" answers.
-
Apache Cassandra Scalability: Allow Filtering and Partition Keys
Anup Shirolkar, Instaclustr
Measurements of the same query with and without the partition key as the cluster grows: why the slide calls ALLOW FILTERING "not recommended".