What SQL is for
SQL is the language for asking precise questions of relational data. Use it to answer: Which transactions are uncategorised? What automations failed this month? Which device has changed state most often?
SQL is declarative: you describe the result you want; the database works out an efficient way to retrieve it.
The four operations: CRUD
| Operation | SQL verb | Purpose |
|---|---|---|
| Create | INSERT | Add rows |
| Read | SELECT | Query rows |
| Update | UPDATE | Change existing rows |
| Delete | DELETE | Remove rows |
Start every unfamiliar query with SELECT. Mutating data deserves a transaction and a backup mindset.
Read queries: the everyday toolkit
SELECT id, name, state
FROM devices
WHERE enabled = true
ORDER BY name;
SELECT *
FROM transactions
WHERE occurred_at >= DATE '2026-07-01'
AND category = 'Groceries'
ORDER BY occurred_at DESC
LIMIT 50;
Prefer explicit columns over SELECT * in reusable queries. It makes the result contract obvious and prevents surprises when schemas change.
Filter, group, aggregate
SELECT
category,
COUNT(*) AS transactions,
ROUND(SUM(amount), 2) AS total_spent
FROM transactions
WHERE occurred_at >= DATE '2026-07-01'
AND direction = 'expense'
GROUP BY category
HAVING SUM(amount) > 100
ORDER BY total_spent DESC;
Order of thought: FROM rows → WHERE filters rows → GROUP BY groups them → aggregates calculate → HAVING filters groups → ORDER BY sorts final results.
Joins: connect related tables
A primary key uniquely identifies a row; a foreign key references a row in another table.
SELECT
t.occurred_at,
t.amount,
c.name AS category_name
FROM transactions AS t
JOIN categories AS c ON c.id = t.category_id
WHERE t.occurred_at >= CURRENT_DATE - INTERVAL '30 days';
| Join | Keeps | Use when |
|---|---|---|
INNER JOIN | Rows with a match on both sides | You only want categorised transactions |
LEFT JOIN | All rows from left, matches if available | Find transactions missing categories |
SELECT t.id, t.description
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
WHERE c.id IS NULL;
Insert, update, delete safely
INSERT INTO categories (name, kind)
VALUES ('Hardware', 'expense');
UPDATE devices
SET enabled = false
WHERE id = 42;
DELETE FROM import_rows
WHERE imported_at < CURRENT_DATE - INTERVAL '90 days';
Before UPDATE or DELETE, run the same WHERE with SELECT first. Never omit WHERE unless you truly mean every row.
Transactions: your undo boundary
BEGIN;
UPDATE budgets SET limit_amount = 550 WHERE name = 'Groceries';
SELECT name, limit_amount FROM budgets WHERE name = 'Groceries';
ROLLBACK; -- use COMMIT only after review
Use BEGIN / COMMIT for multi-step changes. If anything fails, ROLLBACK preserves consistency.
Model data well
| Rule | Why |
|---|---|
| One fact in one place | Avoid conflicting copies |
| Use stable IDs, not names, as relationships | Names change |
| Add constraints | Database rejects impossible data |
| Store dates as dates/timestamps | Enables correct filtering/sorting |
| Store money as decimal/integer minor units | Avoid floating-point rounding |
| Index columns you filter/join often | Keeps large queries fast |
CREATE TABLE devices (
id INTEGER PRIMARY KEY,
entity_id TEXT NOT NULL UNIQUE,
room TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Constraints are correctness tools
CREATE TABLE readings (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
recorded_at TIMESTAMP NOT NULL,
temperature_c NUMERIC(4,1) NOT NULL CHECK (temperature_c > -60 AND temperature_c < 100)
);
Put rules that must always be true in the database too—not only in application code.
Parameterise every external value
Never build SQL by concatenating user input:
# Good: driver sends values separately from SQL syntax
cursor.execute(
"SELECT * FROM devices WHERE entity_id = %s",
(entity_id,),
)
Parameterisation protects against SQL injection and quoting bugs. It is non-negotiable for any value not hard-coded by you.
SQLite, MariaDB, and PostgreSQL
| Database | Best fit |
|---|---|
| SQLite | Single-machine apps, prototypes, local scripts; one file, no server |
| MariaDB | Existing Docker stacks such as Firefly III; familiar MySQL ecosystem |
| PostgreSQL | New multi-user or analytical applications; rich SQL and strong tooling |
The core SQL here transfers between all three. Differences appear in JSON functions, date syntax, auto-increment IDs, and administration commands—read the target database documentation before relying on dialect-specific features.
Query performance basics
- Use
EXPLAINbefore guessing why a query is slow. - Index columns used often in
WHERE,JOIN, and ordered ranges. - Avoid returning thousands of unused rows; paginate with
LIMIT. - Avoid N+1 queries: fetch related data with a join rather than one query per row.
- Measure on realistic data, not an empty development database.
Useful commands
# SQLite
sqlite3 app.db
.tables
.schema devices
.mode column
.headers on
# MariaDB container, interactive client
docker exec -it firefly-db mariadb -u firefly -p firefly_iii
# Make a compressed MariaDB backup (credentials supplied securely)
docker exec firefly-db mariadb-dump --single-transaction database_name | gzip > backup.sql.gz
Common mistakes
| Mistake | Fix |
|---|---|
DELETE/UPDATE without checking rows | SELECT with the same WHERE first |
| Using floating point for currency | DECIMAL or integer cents |
| Storing comma-separated lists in one field | Create a related table |
| No constraints | Use NOT NULL, UNIQUE, CHECK, foreign keys |
| Building query strings from input | Parameterise |
| Backing up only application files | Back up the database separately and test restore |
Practice exercises
- Make a SQLite database of household devices and query rooms with offline devices.
- Import a sample CSV into a staging table, then validate before moving it to a final table.
- Write a monthly spending summary grouped by category.
- Use a
LEFT JOINto list entries missing an expected relationship.