← Back to Autonomy
A Plain-Language Primer & Practical Reference · No. 1 · By Majid Mazouchi

The Data Stack, Demystified

What SQL, SQLite, PostgreSQL, Docker, and SQLAlchemy actually are, when each earns its place — and a hands-on reference to keep open while you build.

Section 01

SQL

Structured Query Language · "sequel" or "S-Q-L"

SQL is a language, not a program. It's the way you talk to a relational database — a database that stores data in tables of rows and columns, like a set of linked spreadsheets.

Almost every relational database understands SQL. You use it to ask four basic kinds of questions, often called CRUD: Create, Read, Update, Delete.

-- Read: get all customers in Michigan
SELECT name, city FROM customers
WHERE state = 'MI';

-- Create: add a new row
INSERT INTO customers (name, state) VALUES ('Ada', 'MI');
Think of it like

SQL is the grammar you speak; the database is the librarian who understands that grammar and fetches what you asked for.

Practical notes
  • The core SQL keywords are standardized, so skills transfer between databases. But each database adds its own dialect quirks — don't assume 100% portability.
  • Learning SELECT, WHERE, JOIN, and GROUP BY covers the vast majority of everyday querying.
  • SQL is declarative: you describe what you want, not how to get it. The database figures out the efficient path.
Section 02

SQLite

"S-Q-L-ite" · a database in a single file

SQLite is a real SQL database that lives entirely in one file on your disk. There's no separate server to start or manage — your program opens the file and reads/writes it directly.

It is the most widely deployed database in the world, precisely because it's invisible: it's inside your phone, your browser, countless apps, and embedded devices.

Think of it like

A spiral notebook you carry in your bag. Always with you, instant to open, no setup — but only really meant for one person writing at a time.

Reach for it when

  • Prototyping or learning
  • Mobile / desktop apps
  • Single-user or read-heavy tools
  • You want zero configuration

Avoid it when

  • Many users write at once
  • High-traffic web backend
  • You need network access from multiple machines
Practical notes

Python ships with SQLite built in — import sqlite3 works with no install. That makes it the fastest possible way to start practicing SQL today. Its main limitation is concurrent writes: it locks the whole file during a write, so it struggles when many processes write simultaneously.

Section 03

PostgreSQL

"post-gres" or "Postgres" · a database server

PostgreSQL is a full database server — a program that runs continuously in the background and that many clients can connect to over the network at the same time.

It's known for being robust, standards-compliant, and remarkably capable. Beyond ordinary tables it handles JSON documents, full-text search, geographic data, custom data types, and serious concurrency — which is why it's a default choice for production web applications.

Think of it like

A staffed public library with a front desk. It runs whether you're there or not, serves many visitors at once, and enforces the rules — but someone has to keep the building running.

Reach for it when

  • Production web/app backends
  • Many concurrent users
  • Complex queries & data integrity matter
  • You need JSON, geo, or full-text search

Maybe overkill when

  • A throwaway script or demo
  • A single-user local tool
  • You don't want to run a server
Practical notes

The everyday workflow is: develop locally against SQLite or a local Postgres, then deploy to a managed Postgres in production. Because both speak SQL, your queries mostly carry over. The cost of Postgres over SQLite is operational — you have to install, run, secure, and back up a server. This is exactly the chore that Docker (next) was built to soften.

Section 04

Docker

the odd one out — not a database at all

Docker packages software together with everything it needs to run into a self-contained unit called a container. That container then runs identically on any machine that has Docker.

It exists to kill the phrase "but it works on my machine." Instead of installing PostgreSQL directly onto your laptop — fiddling with versions and settings — you run a Postgres container that comes pre-packaged and isolated. Start it, stop it, throw it away, all in seconds.

Think of it like

A shipping container. It doesn't matter what's inside or which ship, truck, or port handles it — the standard box fits everywhere and arrives in the same condition. Docker does that for software.

# Spin up a throwaway Postgres in one command
docker run --name pg -e POSTGRES_PASSWORD=secret \
  -p 5432:5432 -d postgres:16
Practical notes
  • A container is a running instance; an image is the frozen template it's launched from (e.g. postgres:16).
  • Containers are great for databases in development because you can match production's exact version and reset to a clean state instantly.
  • Don't confuse Docker with a virtual machine — containers are far lighter, sharing the host's operating system kernel instead of booting a whole OS.
  • docker compose lets you define a whole stack (app + Postgres + cache) in one file and start it all together.
Section 05

SQLAlchemy

a Python library that sits between your code and the database

SQLAlchemy lets you work with a SQL database using Python objects instead of hand-written SQL strings. It's the bridge between your application code and whatever database sits underneath.

It comes in two layers. Core is a SQL toolkit — you still think in tables, but build queries in Python. The ORM (Object-Relational Mapper) is the popular higher layer: it maps a table to a Python class, so each row becomes an object you can read and modify naturally.

# Define a table as a Python class
class User(Base):
    __tablename__ = "users"
    id   = Column(Integer, primary_key=True)
    name = Column(String)

# Query it without writing SQL by hand
user = session.query(User).filter_by(name="Ada").first()
print(user.id)
Think of it like

A skilled interpreter. You speak Python; it speaks fluent SQL to SQLite, Postgres, or MySQL on your behalf — and translates their replies back into Python objects.

Practical notes
  • Database-agnostic: swap SQLite (development) for PostgreSQL (production) by changing a connection string — your model code stays the same. This is the payoff that ties the whole stack together.
  • Safer: it parameterizes queries for you, which guards against SQL injection.
  • The trade-off: there's a learning curve, and for very complex or performance-critical queries you can still drop down to raw SQL, which SQLAlchemy fully supports.
  • It's the ORM behind Flask (via Flask-SQLAlchemy) and is widely paired with FastAPI.

How they all fit together

A single mental model. Read it top to bottom — that's roughly the path a request takes.

PYTHON
SQLAlchemy — your app speaks Python objects; it translates them into SQL.
LANGUAGE
SQL — the shared grammar every relational database understands.
STORAGE
SQLite (one file, simple) or PostgreSQL (a server, powerful) — where the data actually lives.
ENVIRONMENT
Docker — wraps the database (and your app) in a portable box that runs the same everywhere.

In one sentence: you write SQL — often generated by SQLAlchemy — to query SQLite or PostgreSQL, which you may run inside Docker for a clean, reproducible setup.

Part Two

In Practice

From "I understand the words" to "I can build something." Copy, run, break, fix.

Section 06

Your first 10 minutes

get the tools installed and verified before writing any code

Don't install everything at once. Add each tool only when you actually need it. Here's the order that wastes the least time.

  1. Python (you likely have it) Verify with python3 --version. SQLite comes bundled — no separate install. That alone is enough to start practicing SQL.
  2. A way to view databases Install the free DB Browser for SQLite or the SQLite VS Code extension so you can see your tables, not just imagine them.
  3. SQLAlchemy When you want Python objects instead of raw SQL: pip install sqlalchemy. Add psycopg2-binary later if you connect to Postgres.
  4. Docker Desktop Only when you're ready for Postgres. Install from docker.com, then verify with docker --version and docker run hello-world.
  5. PostgreSQL (via Docker) You don't install Postgres directly — you run the official image. Verify the container is alive with docker ps.
Practical note

If a command "isn't found," the tool either isn't installed or isn't on your PATH. Reinstalling and restarting your terminal fixes this 90% of the time.

Section 07

SQLite or PostgreSQL?

the question every junior asks — here's the short answer

When you're unsure, start with SQLite. You can almost always migrate to Postgres later, and with SQLAlchemy that move is mostly a one-line change.

Will more than one user (or process) write data at the same time, over a network?
No / not yet

SQLite

  • Learning & prototypes
  • Desktop / mobile apps
  • Single-user CLI tools
  • Test suites & demos
Yes

PostgreSQL

  • Web / API backends
  • Multiple concurrent writers
  • Data you can't afford to lose
  • Need JSON, geo, full-text search
Practical note

"I might scale someday" is not a reason to start with Postgres. Premature complexity slows you down now for a problem you may never have. Build with SQLite, keep your data access behind SQLAlchemy, and switching later is cheap.

Section 08

Connection strings, decoded

the cryptic URL that tells your code where the database is

A connection string (or "database URL") packs every detail needed to reach a database into one line. Once you can read its parts, half the mystery disappears.

postgresql+psycopg2://majid:secret@localhost:5432/vhm_db
postgresql+psycopg2
The dialect + driver: which database, and which Python library talks to it.
majid
The username the database knows you by.
secret
The password. Never hardcode this — see gotcha #5.
localhost:5432
The host and port. 5432 is Postgres's default; with Docker you map it to your machine.
vhm_db
The database name — one server can hold many databases.

SQLite's version is much simpler because there's no server, user, or port — just a file path:

# Relative file in the current folder
sqlite:///app.db

# Absolute path (note the FOUR slashes)
sqlite:////home/majid/data/app.db

# Pure in-memory, vanishes when the program exits — great for tests
sqlite:///:memory:
Section 09

End-to-end walkthrough

zero to a working database-backed script — the payoff

This is the whole stack in one go: run Postgres in Docker, connect with SQLAlchemy, create a table, insert and read rows. Then we flip a single line to use SQLite instead.

Step 1 — Start PostgreSQL in Docker. One command gives you a real database server running in the background.

docker run --name learn-pg \
  -e POSTGRES_USER=majid \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=vhm_db \
  -p 5432:5432 \
  -v pgdata:/var/lib/postgresql/data \
  -d postgres:16

The -v pgdata:... part is a volume — it keeps your data even if the container is deleted. Skipping it is gotcha #6.

Step 2 — Install the Python pieces.

pip install sqlalchemy psycopg2-binary

Step 3 — Write one script that does everything.

# app.py
from sqlalchemy import create_engine, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

# 1. The connection string decides WHERE data goes.
#    Swap this one line for SQLite and nothing else changes.
DB_URL = "postgresql+psycopg2://majid:secret@localhost:5432/vhm_db"
engine = create_engine(DB_URL, echo=True)  # echo prints the SQL it runs

# 2. Describe a table as a Python class.
class Base(DeclarativeBase): pass

class Sensor(Base):
    __tablename__ = "sensors"
    id:   Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    unit: Mapped[str] = mapped_column(String(10))

# 3. Create the table in the database (CREATE TABLE for you).
Base.metadata.create_all(engine)

# 4. Insert rows.
with Session(engine) as s:
    s.add_all([
        Sensor(name="battery_temp", unit="C"),
        Sensor(name="motor_rpm",   unit="rpm"),
    ])
    s.commit()  # <-- without this, nothing is saved (gotcha #1)

# 5. Read them back.
with Session(engine) as s:
    for row in s.scalars(select(Sensor)):
        print(row.id, row.name, row.unit)

Step 4 — Run it.

python app.py
The whole point

To run this against SQLite instead — no Docker, no server, no install — change only the connection string:

DB_URL = "sqlite:///vhm_local.db"

Same models, same queries, same code. That portability is exactly why SQLAlchemy sits in the middle of the stack.

Section 10

Two tables & a JOIN

relationships — where the real power (and confusion) begins

Real data is connected: a sensor produces many readings; a reading belongs to one sensor. You express that link with a foreign key, and you combine the tables with a JOIN.

In raw SQL, a foreign key and a join look like this:

-- readings.sensor_id points back to sensors.id
CREATE TABLE readings (
  id        SERIAL PRIMARY KEY,
  sensor_id INTEGER REFERENCES sensors(id),
  value     REAL
);

-- JOIN: stitch each reading to the sensor it belongs to
SELECT s.name, r.value
FROM readings r
JOIN sensors s ON s.id = r.sensor_id
WHERE s.unit = 'C';

SQLAlchemy lets you declare the relationship once, then walk it like normal Python attributes — no JOIN by hand:

from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship

class Reading(Base):
    __tablename__ = "readings"
    id:        Mapped[int]   = mapped_column(primary_key=True)
    sensor_id: Mapped[int]   = mapped_column(ForeignKey("sensors.id"))
    value:     Mapped[float]
    sensor:    Mapped["Sensor"] = relationship(back_populates="readings")

# On the Sensor class, add the other side:
#   readings: Mapped[list["Reading"]] = relationship(back_populates="sensor")

# Now traversal is just dot-access:
reading.sensor.name        # the parent sensor
sensor.readings            # all child readings
The three JOINs you'll actually use

INNER JOIN keeps only rows that match on both sides. LEFT JOIN keeps every row from the left table, filling blanks with NULL where there's no match. FULL JOIN keeps everything from both. Ninety percent of the time you want INNER or LEFT.

Section 11

Migrations

changing a table's shape without losing data

create_all() is fine for a fresh database, but it won't alter a table that already exists. Once real data lives in your tables, you change their structure with migrations — versioned, repeatable scripts.

The standard tool with SQLAlchemy is Alembic. The idea: each change (add a column, rename a table) becomes a small file checked into git, so every teammate and every server applies the exact same steps in the exact same order.

pip install alembic
alembic init migrations          # one-time setup

# after you edit your models:
alembic revision --autogenerate -m "add status column"
alembic upgrade head             # apply pending migrations
Why this matters

Hand-editing a production table with raw ALTER TABLE is how data gets lost and environments drift apart. Migrations make schema changes reviewable, reversible, and identical everywhere. Adopt them the moment more than one person — or more than one machine — touches the database.

Section 12

Cheat sheets

the commands you'll forget and look up again — keep this open

SQL — everyday queries

SELECT * FROM t;Read every column and row (avoid * in real apps).
WHERE x > 10 AND y = 'a';Filter rows by conditions.
ORDER BY x DESC;Sort results, high to low.
LIMIT 10;Return at most 10 rows.
COUNT(*), AVG(x), SUM(x)Aggregate functions.
GROUP BY category;Collapse rows into groups for aggregates.
JOIN b ON b.id = a.b_idCombine two tables on a matching key.
INSERT INTO t (..) VALUES (..);Add a row.
UPDATE t SET x=1 WHERE id=5;Change rows (always use WHERE!).
DELETE FROM t WHERE id=5;Remove rows (always use WHERE!).

Docker — daily commands

docker psList running containers.
docker ps -aList all containers, including stopped.
docker start / stop <name>Start or stop a container.
docker logs <name>See a container's output (great for errors).
docker exec -it <name> bashOpen a shell inside a container.
docker rm <name>Delete a stopped container.
docker imagesList downloaded images.
docker compose up -dStart a whole multi-service stack.
docker compose downStop and remove that stack.

psql — inside the Postgres shell

docker exec -it learn-pg psql -U majid -d vhm_dbOpen psql in your container.
\lList databases.
\dtList tables.
\d tablenameDescribe a table's columns.
\duList users/roles.
\qQuit.

SQLAlchemy — core patterns

create_engine(url)The entry point — your handle to the database.
Base.metadata.create_all(engine)Create all defined tables.
with Session(engine) as s:Open a session (a unit of work).
s.add(obj) / s.add_all([..])Stage new rows.
s.commit()Save staged changes. Required.
s.scalars(select(Model))Run a query, get objects back.
.where(Model.x == 5)Filter a query.
s.delete(obj)Mark a row for deletion.
Section 13

Common mistakes & gotchas

the bugs that cost juniors an afternoon — learn them cheaply here
#1Forgetting to commit

You add rows, the program runs clean, but the database is empty. Staged changes only persist when you commit().

Symptom "My data disappears." Fix Call session.commit() after writes (or use a with-block that commits).

#2SQL injection from string-building

Pasting user input straight into a query string lets attackers rewrite it.

BAD  f"SELECT * FROM users WHERE name = '{name}'"
GOOD use parameters — SQLAlchemy does this for you, or:
     cursor.execute("... WHERE name = ?", (name,))

Never build SQL by concatenating variables. Always pass them as parameters.

#3SELECT * in real code

Fine when exploring; risky in applications. It pulls columns you don't need, breaks when the schema changes, and hides intent.

Fix Name the columns you actually use: SELECT id, name FROM ...

#4The N+1 query problem

You fetch 100 sensors, then loop and lazily access sensor.readings for each — firing 1 + 100 separate queries. It's invisible until it's slow.

Fix Load related rows up front with eager loading, e.g. select(Sensor).options(selectinload(Sensor.readings)).

#5Hardcoding passwords & secrets

A database password committed to git is a leak that lives in history forever.

Fix Keep secrets in environment variables or a .env file that's listed in .gitignore, and read them with os.environ.

#6Losing data when a container is removed

By default a container's filesystem is ephemeral. docker rm the Postgres container without a volume and every row is gone.

Fix Always attach a volume (-v pgdata:/var/lib/postgresql/data) so data outlives the container.

#7UPDATE / DELETE with no WHERE

One missing clause and you've changed or deleted every row in the table. There's no undo.

Fix Write the WHERE first. Test destructive statements with a SELECT using the same condition before running them.

Section 14

Troubleshooting

decoding the error messages you'll meet first
connection refused / could not connect to server

Nothing is listening where you pointed. Is the container running (docker ps)? Is the port mapped (-p 5432:5432) and matching your connection string?

relation "x" does not exist

Postgres-speak for "no such table." You probably forgot create_all() / a migration, or you're connected to the wrong database.

password authentication failed for user

The username/password in your connection string doesn't match what the server expects. Re-check the -e POSTGRES_USER / POSTGRES_PASSWORD you started the container with.

port is already allocated

Something else (often an old container) already holds 5432. Stop it, or map a different host port like -p 5433:5432.

database is locked (SQLite)

Two writers hit the single file at once — SQLite's core limitation. For a real concurrent workload, this is your signal to move to Postgres.

Section 15

Glossary

the jargon, in one plain line each
Primary key
A column whose value uniquely identifies each row (usually id).
Foreign key
A column that points to another table's primary key, creating a link between them.
Index
A lookup structure that makes searches on a column fast — like a book's index. Costs a little write speed for a lot of read speed.
Schema
The shape of your database: which tables exist and what columns they have.
Migration
A versioned script that changes the schema in a safe, repeatable way.
Transaction
A group of changes that either all succeed or all roll back — never half-done.
ACID
The four guarantees of a reliable transaction: Atomicity, Consistency, Isolation, Durability.
ORM
Object-Relational Mapper — maps database rows to objects in your programming language (the role SQLAlchemy plays).
Connection pool
A set of reusable open connections, so your app doesn't pay to open a new one for every query.
Session
In SQLAlchemy, a workspace that tracks your pending changes until you commit them.
Volume
Docker storage that lives outside a container, so data survives when the container is removed.
Image vs container
An image is the frozen template; a container is a running instance launched from it.
Section 16

Vectors, pgvector & vector databases

the modern addition — storing meaning, not just values

A relational database is built to match exact values: WHERE state = 'MI'. A vector database answers a different question — "what is most similar in meaning?" — which is the engine behind semantic search and RAG (retrieval-augmented generation).

When you pass text through an embedding model, it comes back as a long list of numbers — a vector — that captures the text's meaning. Texts about similar ideas produce vectors that sit close together in space. So "find related documents" becomes a geometry problem: find the stored vectors nearest to the query's vector.

Think of it like

A library where books aren't shelved by title but by idea. Ask for something about "engine overheating" and you're handed the shelf nearest that meaning — even books that never use those exact words.

So the question juniors ask: can I just use Postgres or SQLite for this, or do I need a special database? Three honest answers:

PostgreSQL + pgvectorYes. An extension adds a real vector type and similarity search. One database for your normal data and your embeddings.
SQLiteNot really. You can stuff numbers in a blob, but there's no native similarity search. Skip it for vectors.
Dedicated vector DBPinecone, Weaviate, Milvus, Qdrant. Purpose-built for vectors at large scale, with extra search features.

pgvector — Postgres learns vectors

pgvector is an open-source extension that teaches PostgreSQL a vector column type plus distance operators for nearest-neighbor search. By default it does exact search; you can add an index (HNSW or IVFFlat) to trade a little accuracy for a lot of speed.

The easiest start is the prebuilt Docker image — same workflow as Section 09, just a different image:

docker run --name vec-pg \
  -e POSTGRES_PASSWORD=secret \
  -p 5432:5432 -d pgvector/pgvector:pg16

# then enable the extension, once per database
CREATE EXTENSION IF NOT EXISTS vector;

With SQLAlchemy, a vector is just another column type. Define it, store embeddings, and order by distance to find the closest matches:

from pgvector.sqlalchemy import Vector
from sqlalchemy import select
from sqlalchemy.orm import Mapped, mapped_column

class Doc(Base):
    __tablename__ = "docs"
    id:        Mapped[int] = mapped_column(primary_key=True)
    content:   Mapped[str]
    embedding = mapped_column(Vector(1536))  # 1536 = model's output size

# Find the 5 docs closest in meaning to a query vector
q = [0.21, -0.44, 0.83, ...]            # from your embedding model
stmt = select(Doc).order_by(
    Doc.embedding.cosine_distance(q)    # also: l2_distance, max_inner_product
).limit(5)
results = session.scalars(stmt).all()

Install the helper with pip install pgvector. The vector's dimension (here 1536) must match whatever embedding model you use.

pgvector or a dedicated vector DB?

Stay on pgvector when

  • You're prototyping or learning RAG
  • Up to ~a few million vectors
  • You already run Postgres and want one system to back up and operate
  • You want to filter by normal SQL columns and similarity in one query

Reach for a dedicated DB when

  • Tens of millions of vectors or more
  • You need very low latency at high query volume
  • You want built-in hybrid (keyword + semantic) search, reranking, sharding
  • A managed service would save you real ops time
Practical note

The honest default for most projects — and almost every junior building their first RAG system — is Postgres + pgvector. One database, simpler operations, and the ability to mix similarity search with ordinary WHERE filters. Move to a dedicated vector database only when scale or latency actually forces the issue; "I might need it someday" is the same trap as reaching for Postgres over SQLite too early (Section 07). In a large production system you may end up running both: Postgres for records and metadata, a vector DB alongside it for the embeddings.

Section 17

References & further reading

Official documentation is the most reliable source for each; the tutorials below are good on-ramps.

  1. SQL — Mode SQL Tutorial, an interactive introduction. mode.com/sql-tutorial. See also the W3Schools SQL reference at w3schools.com/sql.
  2. SQLite — Official documentation and the "Appropriate Uses For SQLite" page. sqlite.org/docs.html · sqlite.org/whentouse.html.
  3. PostgreSQL — Official manual and tutorial. postgresql.org/docs.
  4. Docker — Official "Get Started" guide. docs.docker.com/get-started. Postgres image on Docker Hub: hub.docker.com/_/postgres.
  5. SQLAlchemy — Official Unified Tutorial (covers both Core and ORM). docs.sqlalchemy.org/en/20/tutorial.
  6. Python's built-in SQLite — Standard library docs. docs.python.org/3/library/sqlite3.
  7. Alembic (migrations) — Tutorial for the standard SQLAlchemy migration tool. alembic.sqlalchemy.org.
  8. Docker volumes — How to persist data beyond a container's life. docs.docker.com/storage/volumes.
  9. SQL JOINs, visually — A classic illustrated explanation of join types. Search "SQL joins visual explanation" for the Venn-diagram guides.
  10. pgvector — Official extension repo, with install, indexing, and query docs. github.com/pgvector/pgvector. Python/SQLAlchemy helper: github.com/pgvector/pgvector-python.