Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Database Management Systems (DBMS)  Module 2 – The relational model, keys and integrity

Keys: super, candidate, primary, alternate, foreign

Superkeys, candidate, primary, alternate and foreign keys with examples, how to count superkeys, and when to use natural keys, identity columns or UUIDs.

  • Beginner
  • 25 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Define superkey, candidate key, primary key, alternate key and foreign key with examples
  • Count superkeys from candidate keys with the power-set and inclusion-exclusion rules
  • Choose between natural and surrogate keys, including identity columns, UUIDv4 and UUIDv7

Before you start

What you’ll learn · Before you start

What you will learn

  • Define superkey, candidate key, primary key, alternate key and foreign key with examples
  • Count superkeys from candidate keys with the power-set and inclusion-exclusion rules
  • Choose between natural and surrogate keys, including identity columns, UUIDv4 and UUIDv7

Before you start

On this page

A key is a set of attributes whose values pick out exactly one tuple of a relation. Any set of attributes that does this is a superkey. A superkey from which no attribute can be removed without losing that property is a candidate key. The designer chooses one candidate key as the primary key, and the candidates not chosen are the alternate keys. A foreign key is different in kind: it is a set of attributes in one relation whose values must match a key of another relation (or of the same one), and it is how relations refer to each other. This lesson defines each kind with one running example, counts superkeys with two short rules checked by a program, and then turns to the practical choice every schema faces: a natural key from the real world, or a surrogate key such as an identity column, a UUIDv4 or a UUIDv7.

Superkeys and candidate keys

Take student(roll_no, email, name, programme), where the college’s rules say that no two students share a roll number and no two share an email address. A superkey is any set of attributes whose values no two tuples can share in any instance the rules allow. {roll_no} is a superkey, and so is {roll_no, name}: if roll numbers never repeat, a roll number together with a name never repeats either. Every relation has at least one superkey, the set of all its attributes, because a relation never contains the same tuple twice.

A key is a rule about every instance the database may ever hold, not a fact about the rows that happen to be there. Data can prove that a set of attributes is not a key, with two rows that agree on it, but no amount of data can prove that it is one:

Unique today, not a key Python · unique_in_this_instance.py
"""Which attribute sets are unique in one instance of student? Data can rule a key out, never prove one."""
from itertools import combinations

ATTRIBUTES = ("roll_no", "email", "name", "programme")
rows = [
    (101, "asha@uni.example", "Asha", "BTech"),
    (102, "bilal@uni.example", "Bilal", "MSc"),
    (103, "chitra@uni.example", "Chitra", "BTech"),
    (104, "dev@uni.example", "Dev", "PhD"),
]


def unique_sets(rows):
    """The minimal attribute sets whose values no two rows share."""
    found = []
    for size in range(1, len(ATTRIBUTES) + 1):
        for names in combinations(range(len(ATTRIBUTES)), size):
            if any(set(names) >= set(smaller) for smaller in found):
                continue  # a smaller unique set is inside it: not minimal
            values = [tuple(row[i] for i in names) for row in rows]
            if len(set(values)) == len(rows):
                found.append(names)
    return [{ATTRIBUTES[i] for i in names} for names in found]


def show(label, rows):
    print(label)
    for s in unique_sets(rows):
        print("   ", "{" + ", ".join(sorted(s)) + "}")


show("Unique in the first 4 rows:", rows)
rows.append((105, "dev.k@uni.example", "Dev", "MSc"))
show("After a second student called Dev joins:", rows)

Output

Unique in the first 4 rows:
    {roll_no}
    {email}
    {name}
After a second student called Dev joins:
    {roll_no}
    {email}
    {name, programme}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unique_in_this_instance.py

With four students, {name} is unique, so the data alone would accept it as a key. One more student called Dev breaks it. The program then reports {name, programme} as unique, which is just as accidental. Only the rules about the real world, here the college’s promise about roll numbers and email addresses, can say which sets are keys.

A candidate key is a superkey with nothing to spare: remove any attribute and it stops being a superkey. {roll_no} and {email} are the candidate keys of student; {roll_no, name} is a superkey but not a candidate, because it still works without name. Codd’s 1970 paper called such a key “nonredundant”: in a combination of domains, no single one may be “superfluous in uniquely identifying each element”. Minimal is not the same as smallest. In exam_seat(hall, seat, roll_no), where each seat of each hall holds one student and each student sits once, both {hall, seat} and {roll_no} are candidate keys, of two sizes, and neither contains the other.

An attribute that belongs to at least one candidate key is a prime attribute; the others are non-prime. In exam_seat, all three attributes are prime. The distinction matters in the normal forms, which are defined in terms of how non-prime attributes depend on the keys.

Primary, alternate and foreign keys

When a relation has several candidate keys, one of them becomes the primary key. Codd wrote that “one of them is arbitrarily selected and called the primary key”; in practice the choice goes to the key that is short, never changes and is never missing. Entity integrity from the previous lesson applies to it: no part of a primary key may be NULL.

The other candidate keys are the alternate keys, and they are still keys. Declare each one UNIQUE NOT NULL, or the database does not know about it: without UNIQUE it accepts two students with one email address, and with UNIQUE alone it accepts any number of students with no email at all, because NULLs are not equal to each other.

A foreign key connects two relations. Codd defined it as a domain or combination of domains of R that is not the primary key of R but whose values are values of the primary key of some relation S, adding that S and R may be the same relation. Three consequences are easy to miss:

  • A foreign key is not a key of its own relation. One student has many enrolments, so roll_no repeats in enrolment as often as needed.
  • It must refer to a key. PostgreSQL requires the referenced columns to be a primary key, a unique constraint or a non-partial unique index, and SQLite likewise needs the parent columns to be the primary key or collectively unique.
  • It may refer to its own table, as manager_id in an employee table refers to employee(emp_id).
Nested boxes: superkeys contain the candidate keys; roll_no is the primary key, email the alternate key; enrolment refers to it.Superkeys of studentenrolment(roll_no,course, term)Its foreign key roll_nomust match the primarykey of some studentstudent(roll_no, email,name, programme)Every attribute set no twostudents share, such as{roll_no, name} or{email, programme}:12 sets in allCandidate keyssuperkeys withnothing to sparePrimary key: {roll_no}the candidate chosenAlternate key: {email}declared UNIQUENOT NULLrefers to theprimary key

Superkeys, candidate keys, the primary key and a foreign key that refers to it

Text description of the diagram

The diagram shows one box at the top and a set of nested boxes below it.

  1. The top box is the relation enrolment(roll_no, course, term). Its foreign key roll_no must match the primary key of some student, and an arrow labelled "refers to the primary key" points down to the student boxes.
  2. The large box holds the superkeys of student(roll_no, email, name, programme): every attribute set that no two students share, such as {roll_no, name} or {email, programme}, twelve sets in all.
  3. Inside it, a box holds the candidate keys. A note in it says that they are the superkeys with nothing to spare.
  4. Two more boxes inside the candidate keys show the primary key, {roll_no}, the candidate that was chosen, and the alternate key, {email}, which is declared UNIQUE NOT NULL.

Here are the three kinds of key declared in SQLite, with one insert that breaks each:

A primary key, an alternate key and a foreign key SQL · declare_keys.sql
PRAGMA foreign_keys = ON;   -- SQLite checks foreign keys only after this, on every connection

CREATE TABLE department (dept_id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE student (
  roll_no INTEGER PRIMARY KEY,                                 -- the primary key
  email   TEXT NOT NULL UNIQUE,                                -- an alternate key
  name    TEXT NOT NULL,
  dept_id INTEGER NOT NULL REFERENCES department (dept_id)     -- a foreign key
);
INSERT INTO department VALUES (1, 'Computer Science'), (2, 'Physics');
INSERT INTO student VALUES (101, 'asha@uni.example', 'Asha', 1);

INSERT INTO student VALUES (101, 'asha.r@uni.example', 'Asha R', 2);  -- same primary key
INSERT INTO student VALUES (102, 'asha@uni.example', 'Asha K', 2);    -- same alternate key
INSERT INTO student VALUES (103, 'chitra@uni.example', 'Chitra', 9);  -- no department 9
INSERT INTO student VALUES (104, 'dev@uni.example', 'Dev', 1);        -- a foreign key value may repeat

SELECT s.roll_no, s.name, d.name AS department
FROM student AS s JOIN department AS d ON d.dept_id = s.dept_id
ORDER BY s.roll_no;

Output (exit status 1)

┌─────────┬──────┬──────────────────┐
│ roll_no │ name │    department    │
├─────────┼──────┼──────────────────┤
│ 101     │ Asha │ Computer Science │
│ 104     │ Dev  │ Computer Science │
└─────────┴──────┴──────────────────┘

Printed as an error (standard error)

Runtime error near line 13: UNIQUE constraint failed: student.roll_no
Runtime error near line 14: UNIQUE constraint failed: student.email
Runtime error near line 15: FOREIGN KEY constraint failed

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < declare_keys.sql

The primary key refused a second 101, the alternate key refused a second asha@uni.example, and the foreign key refused a student in department 9, which does not exist. Dev’s row shares a department with Asha’s and is accepted: foreign key values may repeat. Note the first line of the script. SQLite checks foreign keys only after PRAGMA foreign_keys = ON, on every connection; the next lesson shows what happens without it.

You will meet a few more names. A composite or compound key has several attributes, such as {hall, seat}. A natural key comes from the real world and a surrogate key is made up by the system; the last two sections are about choosing between them. “Secondary key” is used both for alternate keys and for any indexed attribute, so say which one you mean.

Counting superkeys

Questions such as “how many superkeys does this relation have?” test whether the definitions are clear, and two rules answer them.

One candidate key. Let R have n attributes and a single candidate key of k attributes. A superkey must contain those k attributes, and each of the other n − k attributes can be in it or not, so there are 2n−k superkeys. R(A, B, C, D) with the key {A} has 23 = 8.

Several candidate keys. A set is a superkey if it contains at least one candidate key, so the count is the size of a union, and inclusion–exclusion gives it. For two keys K₁ and K₂:

  • add the supersets of each key: 2n−|K₁| + 2n−|K₂|;
  • subtract the sets counted twice, the supersets of both, which are the supersets of K₁ ∪ K₂: 2n−|K₁ ∪ K₂|.

For R(A, B, C, D) with the keys {A} and {B} that is 8 + 8 − 4 = 12. Counting the other way round gives the same answer and is often quicker: R has 24 = 16 attribute sets, and the sets that contain neither A nor B are the four subsets of {C, D}, so 16 − 4 = 12. Overlapping keys work the same way. With {A, B} and {B, C}, the union is {A, B, C}, so the count is 4 + 4 − 2 = 6. For three or more keys the pattern continues: add the terms for single keys, subtract those for every pair, add those for every triple, always with 2 raised to n minus the size of the union.

The usual mistake is to stop after the first step and add the counts for each key; with the keys {A} and {B} that gives 16 instead of 12. This program counts both ways, by checking every attribute set and by the formula, for four worked cases and then for 500 random relations:

Superkeys counted two ways Python · superkeys_by_counting.py
"""Count superkeys two ways, by checking every attribute set and by the counting rules, and compare them."""
import random
from itertools import combinations


def by_brute_force(attributes, keys):
    """Every subset of the attributes that contains at least one candidate key."""
    count = 0
    for size in range(len(attributes) + 1):
        for subset in combinations(attributes, size):
            if any(key <= set(subset) for key in keys):
                count += 1
    return count


def by_formula(attributes, keys):
    """Inclusion-exclusion: add 2^(n - |K|) for each key, subtract the sets counted twice, and so on."""
    n = len(attributes)
    total = 0
    for r in range(1, len(keys) + 1):
        for group in combinations(keys, r):
            union = set().union(*group)
            total += (-1) ** (r + 1) * 2 ** (n - len(union))
    return total


for attributes, keys in [("ABCD", ["A"]), ("ABCD", ["A", "B"]), ("ABCD", ["AB", "BC"]), ("ABCDEF", ["AB", "CD", "EF"])]:
    key_sets = [set(k) for k in keys]
    print(f"R({','.join(attributes)}) with keys {', '.join(keys)}: "
          f"{by_brute_force(attributes, key_sets)} by checking, {by_formula(attributes, key_sets)} by formula")

rng = random.Random(42)
agree = 0
for _ in range(500):
    n = rng.randint(1, 8)
    attributes = "ABCDEFGH"[:n]
    keys = [set(rng.sample(attributes, rng.randint(1, n))) for _ in range(rng.randint(1, 3))]
    agree += by_brute_force(attributes, keys) == by_formula(attributes, keys)
print(f"500 random schemas of 1 to 8 attributes with 1 to 3 keys: the two counts agree in {agree} of 500")

Output

R(A,B,C,D) with keys A: 8 by checking, 8 by formula
R(A,B,C,D) with keys A, B: 12 by checking, 12 by formula
R(A,B,C,D) with keys AB, BC: 6 by checking, 6 by formula
R(A,B,C,D,E,F) with keys AB, CD, EF: 37 by checking, 37 by formula
500 random schemas of 1 to 8 attributes with 1 to 3 keys: the two counts agree in 500 of 500

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 superkeys_by_counting.py

The brute-force count checks all 2n attribute sets, which is quick for the eight attributes or fewer used here but doubles with every attribute; the formula needs one term per group of keys instead. In R(A, B, C, D, E, F) with the keys AB, CD and EF, 37 of the 64 attribute sets are superkeys.

Natural keys and surrogate keys

A natural key is an identifier that exists outside the database: an ISBN for a book, an email address for a user, a two-letter ISO country code. It means something to people and is unique without any help from you. A surrogate key is a value the system makes up, usually a number or a UUID, that means nothing and therefore never needs to change. Natural keys have three common weaknesses. They change: people change their email addresses. They get reused: a phone number that is given up is later issued to someone else. And they can be long, composite or personal data that should not be copied into every table that refers to them.

Most schemas use a surrogate as the primary key and keep the natural key as an alternate key. PostgreSQL makes an identity column with GENERATED ALWAYS AS IDENTITY; its documentation explains that such a column takes its values from an implicit sequence, is implicitly NOT NULL, and rejects a value you supply unless the INSERT says OVERRIDING SYSTEM VALUE. In SQLite, a column declared exactly INTEGER PRIMARY KEY becomes another name for the row’s built-in rowid, and a type such as INT or BIGINT does not do this. The surrogate does not replace the natural key’s rule, though:

A surrogate key alone lets the same book in twice SQL · natural_key_needs_unique.sql
-- A surrogate key alone: the same book can be stored twice under two ids.
CREATE TABLE book (book_id INTEGER PRIMARY KEY, isbn TEXT NOT NULL, title TEXT NOT NULL);
INSERT INTO book (isbn, title) VALUES ('978-0-00-000000-2', 'Database Systems');
INSERT INTO book (isbn, title) VALUES ('978-0-00-000000-2', 'Database Systems');
SELECT book_id, isbn FROM book;

-- Keep the surrogate key for joins, and declare the natural key UNIQUE as well.
CREATE TABLE book2 (book_id INTEGER PRIMARY KEY, isbn TEXT NOT NULL UNIQUE, title TEXT NOT NULL);
INSERT INTO book2 (isbn, title) VALUES ('978-0-00-000000-2', 'Database Systems');
INSERT INTO book2 (isbn, title) VALUES ('978-0-00-000000-2', 'Database Systems');
SELECT book_id, isbn FROM book2;

Output (exit status 1)

┌─────────┬───────────────────┐
│ book_id │       isbn        │
├─────────┼───────────────────┤
│ 1       │ 978-0-00-000000-2 │
│ 2       │ 978-0-00-000000-2 │
└─────────┴───────────────────┘
┌─────────┬───────────────────┐
│ book_id │       isbn        │
├─────────┼───────────────────┤
│ 1       │ 978-0-00-000000-2 │
└─────────┴───────────────────┘

Printed as an error (standard error)

Runtime error near line 10: UNIQUE constraint failed: book2.isbn

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < natural_key_needs_unique.sql

The first table stored the same ISBN twice, under the ids 1 and 2, and nothing in it can say which row is the real book. The second table keeps the surrogate for joins and declares the ISBN UNIQUE, so the copy is refused.

Surrogate values have quirks of their own. Without AUTOINCREMENT, SQLite’s documentation says a new row normally gets one more than the largest rowid in the table at that moment, so deleting the newest row can hand its id to the next one:

When SQLite reuses an id SQL · rowid_reuse.sql
-- INTEGER PRIMARY KEY: a new row gets one more than the largest id in the table now.
CREATE TABLE ticket (ticket_id INTEGER PRIMARY KEY, subject TEXT);
INSERT INTO ticket (subject) VALUES ('Login fails'), ('Wrong invoice'), ('Refund');
DELETE FROM ticket WHERE ticket_id = 3;
INSERT INTO ticket (subject) VALUES ('Password reset');
SELECT ticket_id, subject FROM ticket;

-- AUTOINCREMENT: never reuses an id that has existed, at the cost of a bookkeeping table.
CREATE TABLE ticket2 (ticket_id INTEGER PRIMARY KEY AUTOINCREMENT, subject TEXT);
INSERT INTO ticket2 (subject) VALUES ('Login fails'), ('Wrong invoice'), ('Refund');
DELETE FROM ticket2 WHERE ticket_id = 3;
INSERT INTO ticket2 (subject) VALUES ('Password reset');
SELECT ticket_id, subject FROM ticket2;
SELECT name, seq FROM sqlite_sequence;

Output

┌───────────┬────────────────┐
│ ticket_id │    subject     │
├───────────┼────────────────┤
│ 1         │ Login fails    │
│ 2         │ Wrong invoice  │
│ 3         │ Password reset │
└───────────┴────────────────┘
┌───────────┬────────────────┐
│ ticket_id │    subject     │
├───────────┼────────────────┤
│ 1         │ Login fails    │
│ 2         │ Wrong invoice  │
│ 4         │ Password reset │
└───────────┴────────────────┘
┌─────────┬─────┐
│  name   │ seq │
├─────────┼─────┤
│ ticket2 │ 4   │
└─────────┴─────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < rowid_reuse.sql

Ticket 3 was deleted and the next ticket became 3 again, which would confuse anyone holding the old ticket number. With AUTOINCREMENT, SQLite records the largest id ever used in its table sqlite_sequence and moves on to 4. The documentation also advises against AUTOINCREMENT unless it is really needed, because the bookkeeping costs CPU time, memory, disk space and disk I/O, so use it only when ids must never come back. And any counter tells its readers something: an order number of 10,452 suggests how many orders came before it.

UUIDs as keys: version 4 or version 7

A UUID is a 128-bit identifier defined by RFC 9562. Any program can make one without asking a central sequence, so an app server, a phone or an offline client can create rows with their final keys, and data from several databases can be merged without clashes. Two versions matter for keys. UUIDv4 is random: 122 of its bits are random and the rest say which version and variant it is. UUIDv7 begins with a 48-bit count of milliseconds since the start of 1970, followed by random bits, so UUIDv7 values made later sort later.

That ordering matters for indexes. RFC 9562 notes that UUID versions which are not time-ordered, such as UUIDv4, “have poor database-index locality”: each new key lands at a random place in the index. A B-tree index keeps its keys in order across pages, and a page that fills up is split into two half-full pages. When every new key is the largest so far, it goes to the rightmost page and earlier pages stay full; PostgreSQL even fills a B-tree page only to its fillfactor, 90 % by default, when it extends the index at the right, to leave room for later updates. Random keys split pages all over the index instead. Measured on PostgreSQL 18.6, a primary-key index on 100,000 rows filled by INSERT … SELECT FROM generate_series(1, 100000) took about 4.45 MB with gen_random_uuid() keys, 3.18 MB with uuidv7() keys and 2.26 MB with a bigint identity column (sizes from pg_relation_size).

SQLite stores an ordinary table as a B-tree ordered by its rowid, and a WITHOUT ROWID table as a B-tree ordered by its primary key, so you can run the same experiment in the browser:

The same keys in ascending or shuffled order SQL · key_order_and_pages.sql
-- The same 100,000 keys inserted twice, once in ascending order and once in a shuffled order, into an ordinary
-- rowid table and into a WITHOUT ROWID table keyed by 16-byte values, the size of a UUID. Only the order differs.
CREATE TABLE rowid_ascending (id INTEGER PRIMARY KEY, payload BLOB);
CREATE TABLE rowid_shuffled  (id INTEGER PRIMARY KEY, payload BLOB);
CREATE TABLE blob16_ascending (id BLOB PRIMARY KEY, n INTEGER) WITHOUT ROWID;
CREATE TABLE blob16_shuffled  (id BLOB PRIMARY KEY, n INTEGER) WITHOUT ROWID;

-- The shuffle: a Lehmer generator, x(k+1) = 48271 * x(k) mod (2^31 - 1), gives each row a pseudo-random sort key.
CREATE TEMP TABLE source AS
WITH RECURSIVE lcg(n, x) AS (
  SELECT 1, 48271 UNION ALL SELECT n + 1, (x * 48271) % 2147483647 FROM lcg WHERE n < 100000)
SELECT n, x AS shuffle, unhex(printf('%012x%020x', 1760000000000 + n, n)) AS key16 FROM lcg;

CREATE TEMP TABLE size_log (step TEXT, pages INTEGER);
INSERT INTO size_log SELECT 'before', page_count FROM pragma_page_count();
INSERT INTO rowid_ascending SELECT n, zeroblob(16) FROM source ORDER BY n;
INSERT INTO size_log SELECT 'rowid table, ascending order', page_count FROM pragma_page_count();
INSERT INTO rowid_shuffled SELECT n, zeroblob(16) FROM source ORDER BY shuffle;
INSERT INTO size_log SELECT 'rowid table, shuffled order', page_count FROM pragma_page_count();
INSERT INTO blob16_ascending SELECT key16, n FROM source ORDER BY n;
INSERT INTO size_log SELECT 'WITHOUT ROWID, ascending order', page_count FROM pragma_page_count();
INSERT INTO blob16_shuffled SELECT key16, n FROM source ORDER BY shuffle;
INSERT INTO size_log SELECT 'WITHOUT ROWID, shuffled order', page_count FROM pragma_page_count();

-- Pages of 4,096 bytes that each table took.
SELECT now.step, now.pages - prev.pages AS pages_added
FROM size_log AS prev JOIN size_log AS now ON now.rowid = prev.rowid + 1;

Output

┌────────────────────────────────┬─────────────┐
│              step              │ pages_added │
├────────────────────────────────┼─────────────┤
│ rowid table, ascending order   │ 612         │
│ rowid table, shuffled order    │ 672         │
│ WITHOUT ROWID, ascending order │ 691         │
│ WITHOUT ROWID, shuffled order  │ 666         │
└────────────────────────────────┴─────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < key_order_and_pages.sql

The keys and the rows are identical in each pair; only the order of the inserts differs. In the ordinary table the 100,000 keys took 612 pages of 4,096 bytes when inserted in ascending order and 672 when shuffled, about 10 % more: the extra pages are empty space left inside pages by splits. The WITHOUT ROWID table, keyed by 16-byte values the size of a UUID, shows the opposite: 691 pages for the ascending order and 666 for the shuffled one. How much key order saves depends on how an engine fills and splits its pages, so measure on the engine you use before you trust a rule of thumb.

PostgreSQL 18 added uuidv7(), and its release notes call the value “temporally sortable”; gen_random_uuid() and the new alias uuidv4() make version 4 values, and uuid_extract_timestamp() reads the time back out of a version 7 value. Python 3.14 added uuid.uuid7() too:

UUIDv7 sorts by time, and tells the time Python · uuid_order_and_time.py
"""UUIDv7 sorts by creation time and carries that time; UUIDv4 is random (Python 3.14's uuid module)."""
import datetime
import uuid

v7 = [uuid.uuid7() for _ in range(1000)]
v4 = [uuid.uuid4() for _ in range(1000)]
print("1,000 UUIDv7 values, made one after another, already in sorted order:", v7 == sorted(v7))
print("1,000 UUIDv4 values, made one after another, already in sorted order:", v4 == sorted(v4))

# The sample UUIDv7 from PostgreSQL 18's documentation of uuidv7().
sample = uuid.UUID("019535d9-3df7-79fb-b466-fa907fa17f9e")
print("version:", sample.version)
print("first 48 bits as a number of milliseconds:", sample.time)
created = datetime.datetime.fromtimestamp(sample.time / 1000, tz=datetime.UTC)
print("created at (UTC):", created.isoformat(timespec="milliseconds"))

Output

1,000 UUIDv7 values, made one after another, already in sorted order: True
1,000 UUIDv4 values, made one after another, already in sorted order: False
version: 7
first 48 bits as a number of milliseconds: 1740365184503
created at (UTC): 2025-02-24T02:46:24.503+00:00

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 uuid_order_and_time.py

A thousand UUIDv7 values made one after another were already sorted, and the version 4 values were not. The last lines decode the sample UUIDv7 from PostgreSQL’s documentation and print the moment it was made, to the millisecond. That is the privacy cost of UUIDv7: anyone who sees the key can read when the row was created. RFC 9562 calls embedded timestamps “a very small attack surface” that signals the order of creation, recommends UUIDv4 for any security-related use, and forbids using a UUID as a security capability: a value that gives access to whoever holds it. A secret link still needs a random token of its own.

UUID Generator Make version 4 and version 7 UUIDs in bulk, and paste a UUIDv7 to see the creation time it carries.

Four kinds of primary key compared:

  • Identity column: 8 bytes as bigint, made by the database in order, so new keys go to the right end of the index. It hints at how many rows came before, and every row must get its number from one place. Choose it when one database creates every row.
  • UUIDv4: 16 bytes that any program can make without coordination. New keys land anywhere in the index and split pages all over it, so indexes grow larger and slower, but the key reveals nothing. Choose it for keys made on many machines, or for ids that must not hint at anything.
  • UUIDv7: 16 bytes that any program can make without coordination, landing near the right end of the index. The key reveals the millisecond it was made. Choose it for keys made on many machines when that time may be public.
  • Natural key: as long as the identifier is, made by the outside world, and as revealing as its meaning. Its risk is change or reuse in the real world. Choose it only for a stable, short identifier that you would declare UNIQUE anyway.
NanoID, ULID & Snowflake ID Generator Decode a ULID or a Snowflake id to see another way of putting a timestamp at the front of a key.

Interview questions

What is the difference between a superkey and a candidate key?

A superkey is any set of attributes whose values never repeat across the tuples of a relation, in every instance the rules allow, so it identifies each tuple. A candidate key is a minimal superkey: removing any one of its attributes would let two tuples share the remaining values. In student(roll_no, email, name), if roll numbers and emails are both unique, {roll_no, name} is a superkey but not a candidate key, because {roll_no} alone already works; {roll_no} and {email} are the candidate keys. Every candidate key is a superkey, and every superkey contains at least one candidate key. One candidate key is chosen as the primary key; the others become alternate keys.

Key takeaways

  • A superkey identifies every tuple; a candidate key is a superkey with no attribute to spare; minimal is not the same as smallest.
  • Keys are rules about every allowed instance. Data can refute a key with two matching rows but can never prove one.
  • The primary key is the candidate chosen; declare every alternate key UNIQUE NOT NULL so the database enforces it.
  • A foreign key refers to a primary key or UNIQUE columns; its values may repeat and it may refer to its own table.
  • One key of k attributes in n gives 2n−k superkeys; for several keys, use inclusion–exclusion or count the sets that contain no key and subtract.
  • A surrogate primary key still needs the natural key declared UNIQUE, or duplicates of real things slip in.
  • Identity columns are compact and ordered but reveal counts; UUIDv4 reveals nothing but scatters index inserts; UUIDv7 keeps inserts ordered but reveals creation times.

Exercise

Exercise · Easy · Python

Count the superkeys of a relation from its candidate keys

Write count_superkeys(attributes, keys), which returns how many superkeys a relation has. attributes is a string of one-letter attribute names, such as "ABCD" for R(A, B, C, D), and keys is a list of its candidate keys, each a string of attribute names, such as ["A", "B"].

A superkey is any set of the relation's attributes that contains at least one candidate key, the whole set of attributes included. For count_superkeys("ABCD", ["A", "B"]) the answer is 12: the 8 sets that contain A, plus the 8 that contain B, minus the 4 that contain both and were counted twice.

You may check every subset of the attributes, or use the counting rules of the lesson. The sample tests import your function from count_superkeys.py.

Starter code · count_superkeys.py

def count_superkeys(attributes, keys):
    """How many subsets of the attributes contain at least one of the candidate keys?"""
    # This first attempt adds up the supersets of each key, so it counts some sets more than once.
    n = len(attributes)
    return sum(2 ** (n - len(key)) for key in keys)
The sample tests · test_count_superkeys.py
from count_superkeys import count_superkeys


def test_one_key():
    """counts the supersets of a single key"""
    assert count_superkeys("ABCD", ["A"]) == 8


def test_two_single_keys():
    """subtracts the sets that contain both keys"""
    assert count_superkeys("ABCD", ["A", "B"]) == 12


def test_overlapping_keys():
    """handles keys that share an attribute"""
    assert count_superkeys("ABCD", ["AB", "BC"]) == 6


def test_three_keys():
    """works with three keys at once"""
    assert count_superkeys("ABCDE", ["A", "B", "C"]) == 28


def test_key_of_every_attribute():
    """leaves exactly one superkey when the only key is every attribute"""
    assert count_superkeys("ABC", ["ABC"]) == 1
A hint

Checking every subset is simplest: itertools.combinations(attributes, size) for each size from 0 to len(attributes) produces them all, and set(key) <= set(subset) tests whether a subset contains a key. With n attributes there are 2ⁿ subsets, few enough for the sizes here.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 7 In student(roll_no, email, name), roll_no is unique and email is unique; names can repeat. Which set is a candidate key?

    Choose one answer.

    Show the answer to question 1

    Answer: {email}

    {email} is a superkey, and removing its only attribute leaves nothing, so it is minimal: a candidate key. {roll_no, email} and {roll_no, name} are superkeys too, but each still identifies a student with one attribute removed, so they are not minimal. {name} is not a key at all, because two students may share a name.

  2. Question 2 of 7 R(A, B, C, D, E) has exactly one candidate key, {A, B, C}. How many superkeys does it have?

    Type a number.

    Show the answer to question 2

    Answer: 4 superkeys

    A superkey must contain A, B and C; each of the other two attributes, D and E, can be in it or not. That gives 2^(5 - 3) = 2^2 = 4 superkeys: ABC, ABCD, ABCE and ABCDE.

  3. Question 3 of 7 R(A, B, C, D) has the candidate keys {A}, {B} and {C}. How many superkeys does it have?

    Type a number.

    Show the answer to question 3

    Answer: 14 superkeys

    R has 2^4 = 16 attribute sets. A set fails to be a superkey only if it contains none of A, B and C, which leaves the empty set and {D}. So 16 - 2 = 14. Inclusion-exclusion gives the same: 8 + 8 + 8 - 4 - 4 - 4 + 2 = 14.

  4. Question 4 of 7 R(A, B, C, D) has the candidate keys {A, B} and {B, C}. Which attributes are prime?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • B
    • A
    • C

    An attribute is prime when it belongs to at least one candidate key. A and B are in {A, B}, and B and C are in {B, C}. D is in no candidate key, so it is non-prime.

  5. Question 5 of 7 enrolment(roll_no, course) has a foreign key roll_no that refers to student. Which statement is true?

    Choose one answer.

    Show the answer to question 5

    Answer: The same roll_no may appear in many enrolment rows, but each must match a student or be NULL

    A foreign key is a key of the other table, not of its own: one student can have many enrolments, so the value repeats. Each non-NULL value must match a row of the referenced table, and the referenced columns must be a primary key or UNIQUE.

  6. Question 6 of 7 An SQLite table has id INTEGER PRIMARY KEY without AUTOINCREMENT and rows 1, 2 and 3. You delete row 3 and insert a row without giving an id. Which id does it get?

    Choose one answer.

    Show the answer to question 6

    Answer: 3, one more than the largest id left in the table

    Without AUTOINCREMENT, SQLite normally gives a new row one more than the largest rowid in the table at that moment, so a deleted largest id can come back. AUTOINCREMENT remembers the largest id ever used in sqlite_sequence and never reuses it.

  7. Question 7 of 7 Orders are keyed by UUIDv7 and the key appears in the order page address. What can a stranger who sees one address learn from it?

    Choose one answer.

    Show the answer to question 7

    Answer: The millisecond at which the order's key was created

    A UUIDv7 starts with a 48-bit count of milliseconds since 1970, so anyone can read when the key was made. It does not reveal a count of earlier orders, which a value from a sequence would suggest, and RFC 9562 tells implementations not to put MAC addresses into UUIDs.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.