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.
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');
SQL is the grammar you speak; the database is the librarian who understands that grammar and fetches what you asked for.
SELECT, WHERE, JOIN, and GROUP BY covers the vast majority of everyday querying.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.
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.
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.
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.
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.
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.
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.
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
postgres:16).docker compose lets you define a whole stack (app + Postgres + cache) in one file and start it all together.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)
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.
A single mental model. Read it top to bottom — that's roughly the path a request takes.
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.
From "I understand the words" to "I can build something." Copy, run, break, fix.
Don't install everything at once. Add each tool only when you actually need it. Here's the order that wastes the least time.
python3 --version. SQLite comes bundled — no separate install. That alone is enough to start practicing SQL.pip install sqlalchemy. Add psycopg2-binary later if you connect to Postgres.docker --version and docker run hello-world.docker ps.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.
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.
"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.
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.
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:
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
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.
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
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.
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
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.
| 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_id | Combine 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 ps | List running containers. |
| docker ps -a | List 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> bash | Open a shell inside a container. |
| docker rm <name> | Delete a stopped container. |
| docker images | List downloaded images. |
| docker compose up -d | Start a whole multi-service stack. |
| docker compose down | Stop and remove that stack. |
| docker exec -it learn-pg psql -U majid -d vhm_db | Open psql in your container. |
| \l | List databases. |
| \dt | List tables. |
| \d tablename | Describe a table's columns. |
| \du | List users/roles. |
| \q | Quit. |
| 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. |
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).
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.
SELECT * in real codeFine 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 ...
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)).
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.
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.
UPDATE / DELETE with no WHEREOne 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.
Nothing is listening where you pointed. Is the container running (docker ps)? Is the port mapped (-p 5432:5432) and matching your connection string?
Postgres-speak for "no such table." You probably forgot create_all() / a migration, or you're connected to the wrong database.
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.
Something else (often an old container) already holds 5432. Stop it, or map a different host port like -p 5433:5432.
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.
id).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.
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 + pgvector | Yes. An extension adds a real vector type and similarity search. One database for your normal data and your embeddings. |
| SQLite | Not really. You can stuff numbers in a blob, but there's no native similarity search. Skip it for vectors. |
| Dedicated vector DB | Pinecone, Weaviate, Milvus, Qdrant. Purpose-built for vectors at large scale, with extra search features. |
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.
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.
Official documentation is the most reliable source for each; the tutorials below are good on-ramps.