You already know what SQL is and how it's written. It's time to understand what it acts on: the structure where the data lives. In this lesson we'll take the whole hierarchy apart —server, database, schema, table, row, column—, see why the spreadsheet analogy helps at first but breaks down quickly, and walk in detail through PostgreSQL's catalogue of data types. That last point matters more than it looks: choosing a type badly is a mistake you pay for over years, and the case of money stored in floating point is the canonical example of why. We'll finish by learning to inspect tables that already exist, a skill you'll need every time you face somebody else's database.

Contents

  1. The hierarchy: server, database, schema, table
  2. Tables, rows and columns
  3. The spreadsheet analogy (and where it breaks)
  4. Numeric data types
  5. Why money never goes in floating point
  6. Text types
  7. Date and time types
  8. Booleans, UUID and JSONB
  9. NULL: the absence of a value
  10. Equivalences between PostgreSQL, MySQL and SQLite
  11. How to inspect existing tables
  12. Common Mistakes and Tips
  13. Exercises
  14. Conclusion

  1. The hierarchy: server, database, schema, table

In PostgreSQL, objects are organised into four levels:

graph TD
    A["Server / Cluster<br/>(one process, one port: 5432)"] --> B["Database: greenstore"]
    A --> B2["Database: postgres"]
    A --> B3["Database: sql_tests"]
    B --> C["Schema: public"]
    B --> C2["Schema: information_schema"]
    C --> D["Table: products"]
    C --> D2["Table: customers"]
    C --> D3["Table: orders"]
    D --> E["Columns: id, name, price…"]
    D --> E2["Rows: each individual product"]
Level What it is Example in the course
Server (cluster) The running PostgreSQL process, listening on a port The pg-course container on localhost:5432
Database An isolated set of data. A connection only sees one at a time greenstore
Schema A namespace inside the database public
Table A collection of rows with the same structure products
Column A field with a name and a type price NUMERIC(10,2)
Row A specific record The product "Raw orange blossom honey 500 g"

Two practical consequences of this hierarchy:

  1. You can't query two databases at once. If greenstore and sql_tests live on the same server, a query can't combine them directly (you'd need extensions like dblink or postgres_fdw). In MySQL, on the other hand, writing SELECT ... FROM other_database.table is common, because there "database" and "schema" are almost synonyms.
  2. Schemas do combine freely. Inside greenstore you could have a sales schema and an analytics schema and query them together without any trouble.

The public schema

Every PostgreSQL database is born with a schema called public. If you create a table without specifying a schema, it ends up there, and if you query it without a prefix it's looked up there. These two statements are equivalent in our setup:

SELECT name FROM products;
SELECT name FROM public.products;

The lookup order is determined by the search_path parameter:

SHOW search_path;
search_path
"$user", public

It means: "look first for a schema named after the connected user; if it doesn't exist, look in public". Schemas are there to organise large databases (splitting by functional area, by client, by environment) and to avoid name collisions. All of GreenStore lives in public, so we won't have to worry about this again.

  1. Tables, rows and columns

A table is a collection of rows that share the same structure. Every table is defined by:

  • A name (products).
  • An ordered set of columns, each with a name and a data type.
  • Optionally, constraints that limit which values are valid (module 5).

A simplified view of products:

id name category_id price stock active
1 Extra virgin olive oil 500 ml 1 12.50 120 true
2 Organic brown rice 1 kg 1 3.90 200 true
6 Aloe vera face cream 50 ml 2 18.90 60 true

Formal terminology versus everyday terminology:

Everyday term Relational model term What it is
Table Relation The set of data
Row / record Tuple One specific entity
Column / field Attribute A property of that entity
Column type Domain The set of valid values

Three fundamental properties worth burning in from the start:

  1. Every row has the same columns. There's no such thing as a row "with an extra field". If a piece of data doesn't apply, NULL is stored.
  2. Each column has a single type. You can't store 'tomorrow' in a DATE column.
  3. Rows have no intrinsic order. A table is a set. If you don't ask for ORDER BY explicitly, the engine can hand them back in any order, and that order can change tomorrow. Relying on the "natural order" is a classic mistake.

  1. The spreadsheet analogy (and where it breaks)

At first it helps to think of a table as an Excel sheet: the first row holds the headers (columns) and the following ones the data (rows). The analogy works... up to a point.

Aspect Spreadsheet Database table
Types Each cell can contain anything The whole column shares a single type, checked by the engine
Row order Visible and meaningful There's no implicit order; you ask for it with ORDER BY
Relationships Simulated with VLOOKUP and fragile references First-class: foreign keys with guaranteed integrity
Size Thousands or hundreds of thousands of rows Millions or billions
Concurrency One user at a time (or version conflicts) Hundreds of simultaneous users with transactions
Integrity Nothing stops you typing "twelve euros" in a column of amounts Constraints that reject the invalid value
Formulas Stored in the cells The data is stored; the calculation happens at query time
Undo Ctrl+Z ROLLBACK of a transaction (module 9)

The deepest conceptual difference is strict typing. In Excel, a column of prices can hold 12,50, 12.50, "12.50 €" and twelve fifty, and nobody warns you until the total comes out wrong. In PostgreSQL, if the column is NUMERIC(10,2), the engine rejects anything that isn't a number:

INSERT INTO products (name, price) VALUES ('Odd product', 'twelve euros');
ERROR:  invalid input syntax for type numeric: "twelve euros"

That error, which can look like a nuisance, is exactly the value a database brings: incorrect data never gets in.

  1. Numeric data types

Type Size Range / Precision When to use it
SMALLINT 2 bytes -32,768 to 32,767 Very small counters, ages
INTEGER (INT) 4 bytes ±2,147,483,647 The default for integers: ids, stock, quantities
BIGINT 8 bytes ±9.2 × 10¹⁸ Ids for enormous tables, massive counters
NUMERIC(p,s) / DECIMAL(p,s) Variable Exact, up to 131,072 digits Money and any exact calculation
REAL 4 bytes ~6 decimal digits Approximate scientific magnitudes
DOUBLE PRECISION 8 bytes ~15 decimal digits Scientific calculations, coordinates
SERIAL / BIGSERIAL 4/8 bytes Auto-incrementing integer Primary keys (see note)

About NUMERIC(p,s):

  • p (precision) is the total number of digits.
  • s (scale) is how many of those digits go after the decimal point.
  • NUMERIC(10,2) allows up to 99,999,999.99 → eight integer digits and two decimals.

In GreenStore we use NUMERIC(10,2) for price, cost, shipping_cost, unit_price, amount and salary, and NUMERIC(4,2) for discount (a fraction between 0 and 1, with two decimals).

Note on SERIAL: it isn't a real type, but a shortcut that creates an INTEGER plus a sequence that auto-increments it. Since PostgreSQL 10 the form recommended by the standard is GENERATED BY DEFAULT AS IDENTITY, which is the one the course script uses. You'll see it in detail in module 5.

  1. Why money never goes in floating point

REAL and DOUBLE PRECISION store numbers in binary floating point (the IEEE 754 standard). The problem is that many decimals that are exact in base 10 are recurring in base 2: 0.1 in binary is infinite, just as 1/3 is in decimal. The computer stores an approximation, and those tiny errors accumulate.

Check it for yourself:

SELECT 0.1::REAL + 0.2::REAL AS float_sum,
       0.1::NUMERIC + 0.2::NUMERIC AS exact_sum;
float_sum exact_sum
0.30000001 0.3

And the case that would wreck an accounting close for you:

SELECT (0.1::DOUBLE PRECISION + 0.2::DOUBLE PRECISION) = 0.3 AS equal_float,
       (0.1::NUMERIC + 0.2::NUMERIC) = 0.3 AS equal_exact;
equal_float equal_exact
false true

Applied to GreenStore: if we stored price as REAL and added up the 47 order lines in the database, the total might come out as 143.20999999998 instead of 143.21. Multiply that by thousands of orders a month and you have an accounting mismatch that's impossible to justify.

Type Nature Speed Accuracy Correct use
REAL / DOUBLE PRECISION Approximate (binary) Very fast Not exact Physics, statistics, coordinates, approximate averages
NUMERIC(p,s) Exact (decimal) Slower Exact Money, accounting percentages, billable quantities

A rule with no exceptions: money → NUMERIC. Never FLOAT, REAL or DOUBLE PRECISION. The speed penalty is irrelevant next to a lost cent.

There's also a MONEY type in PostgreSQL, but it's not recommended: it depends on the server's regional settings and doesn't handle multiple currencies well. Use NUMERIC.

  1. Text types

Type Description When to use it
VARCHAR(n) Variable-length text with a maximum of n characters When the limit is a real business rule
TEXT Text of unlimited length The default option in PostgreSQL
CHAR(n) Fixed length; padded with spaces up to n Almost never. Only for codes of strictly fixed length

One PostgreSQL quirk that surprises people coming from other engines: TEXT and VARCHAR have exactly the same performance. Internally they're the same type; VARCHAR(n) merely adds a length check. There is no speed advantage in setting a limit.

So when should you use VARCHAR(n)? When the limit means something:

  • country VARCHAR(60): a country name, a reasonable limit.
  • email VARCHAR(120): there's a known practical maximum.
  • description TEXT: we don't know how much anybody will write.
  • comment TEXT: a review can be long.

Avoid CHAR(n). It pads with trailing spaces and causes surprising comparisons:

SELECT 'ES'::CHAR(5) = 'ES' AS look_equal,
       LENGTH('ES'::CHAR(5)) AS stored_length;
look_equal stored_length
true 2

The value is stored as 'ES ' but trailing spaces are ignored when comparing, which creates constant confusion when exporting or concatenating.

Dialect note: in MySQL there are performance and storage differences between CHAR, VARCHAR and TEXT (TEXT columns are stored outside the row and don't accept a default value). What makes no difference here does make a difference there.

  1. Date and time types

Type What it stores Example Use in GreenStore
DATE The date only 2026-02-14 order_date, signup_date, added_date, hire_date
TIME The time only 18:30:00 Opening hours
TIMESTAMP Date and time, without a time zone 2026-02-14 18:30:00 When the zone is irrelevant
TIMESTAMPTZ Date and time with a time zone 2026-02-14 18:30:00+01 Audit stamps, real-world events
INTERVAL A duration 3 days, 2 hours 30 minutes Delivery windows

The distinction between TIMESTAMP and TIMESTAMPTZ is the one that causes the most trouble in production:

  • TIMESTAMP stores literally what you give it. If a French customer and a Spanish one both record "18:30", they're stored identically even though they're different moments.
  • TIMESTAMPTZ converts to UTC on write and to the client's zone on read. It represents a real instant in time.
SELECT NOW() AS now_with_zone,
       NOW()::TIMESTAMP AS now_without_zone,
       CURRENT_DATE AS today,
       AGE(DATE '2026-02-14', DATE '2025-11-14') AS difference;
now_with_zone now_without_zone today difference
2026-02-25 10:14:07.412+01 2026-02-25 10:14:07.412 2026-02-25 3 mons

Rule of thumb: if the moment has real relevance (when something happened), use TIMESTAMPTZ. If it's a calendar date with no time (an order date, a date of birth), use DATE. GreenStore uses DATE for all its dates because they're calendar dates, not instants.

Dialect: MySQL has DATETIME (no zone) and TIMESTAMP (with UTC conversion, but limited to 2038). SQLite has no date type: it stores ISO text, numbers or julian days, and the date functions operate on those representations.

  1. Booleans, UUID and JSONB

BOOLEAN

Stores TRUE, FALSE or NULL. Takes 1 byte. In GreenStore it's used by products.active and suppliers.active.

SELECT TRUE AS is_true, FALSE AS is_false, NULL::BOOLEAN AS is_unknown;

That NULL has business meaning: "active = TRUE" is a product on sale, "FALSE" a discontinued one, and NULL would mean "we haven't decided yet". That's why booleans in SQL have three states, not two.

UUID

A 128-bit universal identifier, in the style of a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11. Takes 16 bytes.

Advantage Drawback
It can be generated on the client without asking the server Takes 4 times as much space as an INTEGER
It doesn't reveal how many records you have Unreadable for humans
Unique across different systems (useful in microservices) Worse index locality: slower inserts

GreenStore uses INTEGER for its id columns because it's a small, didactic database where being able to write WHERE id = 7 is a learning advantage.

JSONB

PostgreSQL lets you store JSON documents in a column. JSON stores the text as-is; JSONB stores it in an indexed binary format, and it's the one used in practice. It's for semi-structured data whose shape varies (attributes specific to each product, responses from a payment gateway, configurations).

SELECT '{"origin": "Valencia", "certificate": "EU-BIO"}'::JSONB -> 'origin' AS origin;
origin
"Valencia"

We mention it here so you know it exists; its full use is covered in lesson 10-06. And a warning: JSONB is no excuse for not designing your tables properly. What is structured belongs in columns.

  1. NULL: the absence of a value

NULL is not zero, nor an empty string, nor FALSE. It means "there's no value here" or "it isn't known".

In GreenStore there are three nulls with a well-defined business meaning:

Column What its NULL means
orders.employee_id The order came in through the web; no sales rep handled it
customers.referred_by_id The customer arrived on their own, nobody referred them
employees.manager_id This is the general manager: they have no superior

The essential thing right now is to understand that NULL propagates through any operation:

SELECT 10 + NULL AS addition,
       'Hello ' || NULL AS concatenation,
       NULL = NULL AS comparison;
addition concatenation comparison
(null) (null) (null)

Any calculation that touches a NULL returns NULL, because operating on something unknown produces something unknown. And as you saw in the previous lesson, to check whether something is null you use IS NULL, never = NULL.

Tell these three cases apart, because they aren't the same thing:

Value Meaning
NULL There's no data / it's unknown
0 There is data, and it's zero
'' (empty string) There is data, and it's a text with no characters

The full treatment of nulls —how they affect aggregations, JOINs and filters— is lesson 04-03. For now the concept is enough.

  1. Equivalences between PostgreSQL, MySQL and SQLite

If you have to port a schema or read somebody else's code, this table will save you time:

Concept PostgreSQL 16 MySQL 8 SQLite 3
Integer INTEGER, BIGINT INT, BIGINT INTEGER
Auto-increment GENERATED AS IDENTITY / SERIAL AUTO_INCREMENT INTEGER PRIMARY KEY AUTOINCREMENT
Exact decimal NUMERIC(p,s) DECIMAL(p,s) NUMERIC (affinity, no guarantee)
Floating point REAL, DOUBLE PRECISION FLOAT, DOUBLE REAL
Short text VARCHAR(n) VARCHAR(n) TEXT
Long text TEXT TEXT, LONGTEXT TEXT
Boolean BOOLEAN (real) TINYINT(1) (alias) No type: 0 / 1
Date DATE DATE TEXT in ISO format
Date and time TIMESTAMP, TIMESTAMPTZ DATETIME, TIMESTAMP TEXT / INTEGER
JSON JSONB (binary, indexable) JSON TEXT + JSON1 functions
UUID UUID (native) CHAR(36) or BINARY(16) TEXT
Type system Strict Strict (with strict mode on) Dynamic: almost anything is accepted

The most dangerous difference is in the last row. SQLite uses type affinity: if you declare a column as INTEGER and insert the text 'hello' into it, it stores it without complaint. That's convenient for prototyping and disastrous for guaranteeing integrity. It's the main reason this course uses PostgreSQL.

  1. How to inspect existing tables

When you join a new project, the first thing to do is understand its schema. There are two routes.

11.1. psql metacommands (fast)

greenstore=# \dt
        List of relations
 Schema |     Name      | Type  |   Owner
--------+---------------+-------+------------
 public | categories    | table | sql_course
 public | customers     | table | sql_course
 public | employees     | table | sql_course
 public | order_lines   | table | sql_course
 public | orders        | table | sql_course
 public | products      | table | sql_course
 public | returns       | table | sql_course
 public | reviews       | table | sql_course
 public | suppliers     | table | sql_course
greenstore=# \d products
                            Table "public.products"
    Column    |         Type          | Nullable |           Default
--------------+-----------------------+----------+------------------------------
 id           | integer               | not null | generated by default as identity
 name         | character varying(150)| not null |
 category_id  | integer               |          |
 supplier_id  | integer               |          |
 price        | numeric(10,2)         | not null |
 cost         | numeric(10,2)         |          |
 stock        | integer               | not null | 0
 active       | boolean               | not null | true
 added_date   | date                  | not null | CURRENT_DATE
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "products_category_id_fkey" FOREIGN KEY (category_id) REFERENCES categories(id)
    "products_supplier_id_fkey" FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
Referenced by:
    TABLE "order_lines" CONSTRAINT ... FOREIGN KEY (product_id) REFERENCES products(id)

A single command gives you columns, types, nullability, defaults, indexes, foreign keys and which other tables point at this one. Use \d+ products to see the size and comments as well.

Metacommand What it shows
\dt Tables in the current schema
\d table_name The full structure of a table
\d+ table_name The above plus size, statistics and comments
\dn Schemas
\di Indexes
\dv Views
\l+ Databases with their size

11.2. information_schema (portable and queryable)

The SQL standard defines a metadata schema called information_schema, available in PostgreSQL, MySQL and SQL Server. Its advantage over \d is that it's ordinary SQL: you can filter it, sort it and use it from any language.

SELECT column_name,
       data_type,
       character_maximum_length,
       numeric_precision,
       numeric_scale,
       is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name = 'products'
ORDER BY ordinal_position;
column_name data_type character_maximum_length numeric_precision numeric_scale is_nullable
id integer (null) 32 0 NO
name character varying 150 (null) (null) NO
category_id integer (null) 32 0 YES
supplier_id integer (null) 32 0 YES
price numeric (null) 10 2 NO
cost numeric (null) 10 2 YES
stock integer (null) 32 0 NO
active boolean (null) (null) (null) NO
added_date date (null) (null) (null) NO

Useful information_schema views:

View What it contains
information_schema.tables All tables and views
information_schema.columns All columns with their types
information_schema.table_constraints Constraints (PK, FK, UNIQUE, CHECK)
information_schema.key_column_usage Which columns take part in each key

And a query you'll use often to draw yourself a quick map of an unfamiliar database:

SELECT table_name, COUNT(*) AS num_columns
FROM information_schema.columns
WHERE table_schema = 'public'
GROUP BY table_name
ORDER BY table_name;
table_name num_columns
categories 3
customers 8
employees 8
order_lines 6
orders 7
products 9
returns 5
reviews 6
suppliers 5

information_schema is the standard, but PostgreSQL also has its own catalogue pg_catalog (pg_tables, pg_class, pg_attribute), more complete and faster, though not portable. The \d metacommands query precisely that catalogue.

Common Mistakes and Tips

  • Storing money in FLOAT or REAL. The most expensive mistake in this lesson. Always NUMERIC(10,2).
  • Storing dates as text. VARCHAR for a date stops you sorting properly, computing differences and validating. Use DATE or TIMESTAMPTZ.
  • Storing numbers that aren't quantities as numbers. A postcode (03001), a phone number or a tax id is text: if you store them as INTEGER you'll lose the leading zero and the +34.
  • Writing VARCHAR(255) out of habit. The 255 comes from old MySQL. In PostgreSQL use TEXT unless there's a real business limit.
  • Using CHAR(n). The space padding causes subtle errors. It's practically never the right choice.
  • Confusing NULL with 0 or ''. They're three different things and they behave differently in filters and aggregations.
  • Relying on row order. Without ORDER BY no order is guaranteed, however sorted they may come out today.
  • Tip: choose the type with five years in mind. Changing a column's type on millions of rows in production is a delicate operation (module 5).
  • Tip: \d table is your first command on anybody else's database. Before writing a query, look at the structure.
  • Tip: use plural table names and singular column names. products.name reads better than product.names.

Exercises

Exercise 1

For each piece of GreenStore data, pick the most suitable PostgreSQL type and justify it in one line:

  1. A product's selling price.
  2. A supplier's country code in ISO format (ES, PT, FR).
  3. The comment on a review.
  4. A review's rating (1 to 5).
  5. The date a customer signed up.
  6. Whether a product is active or not.
  7. The exact instant a payment was confirmed, with customers in three countries.
  8. The discount applied to an order line (a fraction from 0 to 1, two decimals).

Exercise 2

Run these expressions and explain what each one demonstrates:

SELECT 1.0 / 3.0 AS a;
SELECT (1.0 / 3.0)::REAL AS b;
SELECT 100000000.0::REAL + 1 AS c;
SELECT 'abc'::CHAR(6) || '|' AS d;
SELECT NULL + 5 AS e;

Exercise 3

Write a query against information_schema that shows every numeric column in the greenstore database, stating which table it belongs to and with what precision and scale. Sort them by table and by position within the table.

Solutions

Solution 1

Data Type Justification
1. Selling price NUMERIC(10,2) It's money: it demands exact decimal arithmetic
2. ISO country code CHAR(2) or VARCHAR(2) Known fixed length. It's the one situation where CHAR can be defended; VARCHAR(2) avoids the space padding. (GreenStore stores the full country name, so it uses VARCHAR(60))
3. Review comment TEXT Unpredictable length; no business limit
4. Rating 1-5 SMALLINT (or INTEGER) A very small integer. The 1-5 range is guaranteed with a CHECK constraint (module 5), not with the type
5. Signup date DATE A calendar date, with no relevant time
6. Product active BOOLEAN Two states plus the unknown one
7. Instant of payment TIMESTAMPTZ It's a real instant and several time zones are involved
8. Discount NUMERIC(4,2) An exact value between 0.00 and 1.00; it takes part in amount calculations

Solution 2

Expression Result What it demonstrates
1.0 / 3.0 0.33333333333333333333 Decimal literals are numeric: PostgreSQL keeps many exact digits
(1.0/3.0)::REAL 0.33333334 REAL only keeps about 6-7 significant digits: information is lost
100000000.0::REAL + 1 100000000 The +1 vanishes: REAL doesn't have enough precision to tell 100,000,000 from 100,000,001. It's the definitive argument against using floating point for money
'abc'::CHAR(6) || '|' abc| Even though CHAR(6) pads to 6 characters, the concatenation strips the trailing spaces. Inconsistent behaviour that justifies avoiding CHAR
NULL + 5 (null) NULL propagates: any arithmetic operation with a null gives null

Solution 3

SELECT table_name,
       column_name,
       numeric_precision,
       numeric_scale,
       is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
  AND data_type = 'numeric'
ORDER BY table_name, ordinal_position;

Expected result on GreenStore:

table_name column_name numeric_precision numeric_scale is_nullable
employees salary 10 2 YES
order_lines unit_price 10 2 NO
order_lines discount 4 2 NO
orders shipping_cost 10 2 NO
products price 10 2 NO
products cost 10 2 YES
returns amount 10 2 YES

Notice that data_type returns 'numeric' in lowercase, and that precision and scale come in separate columns: information_schema normalises types to the vocabulary of the SQL standard, not to the name you wrote when creating the table.

Conclusion

In this lesson you've walked through the structure SQL acts on:

  • The hierarchy server → database → schema (public) → table → columns and rows, and why you can't query two databases at once in PostgreSQL.
  • A table is a set of rows with the same structure, with no intrinsic order and one type per column checked by the engine: that's the big difference from a spreadsheet.
  • PostgreSQL's catalogue of types: numeric (INTEGER, BIGINT, NUMERIC(p,s), REAL), text (TEXT, VARCHAR(n), CHAR(n)), date and time (DATE, TIMESTAMP, TIMESTAMPTZ, INTERVAL), BOOLEAN, UUID and JSONB.
  • The non-negotiable rule: money goes in NUMERIC, never in floating point, because 0.1 + 0.2 <> 0.3 in binary.
  • NULL as the absence of a value, distinct from 0 and from '', propagating through every operation.
  • The type equivalences between PostgreSQL, MySQL and SQLite, and the danger of SQLite's dynamic type system.
  • How to inspect somebody else's database with \dt, \d table and information_schema.columns.

In the next lesson, The Relational Model: Primary and Foreign Keys, we'll see how tables stop being isolated and start relating to one another: what a primary key is and why we use surrogate id columns, how foreign keys guarantee there are no orders from non-existent customers, what happens when you delete a record that others depend on, how 1:1, 1:N and N:M cardinalities are represented —with order_lines as a real example of a bridge table— and how normalisation explains why GreenStore's schema is split into nine tables and not one.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved