Choosing a Database Engine

5.Which One Would GreenMart Actually Run?

M

In this chapter

An investor asks GreenMart what it's actually running on, forcing an honest look at SQLite vs. real client-server engines — MySQL, PostgreSQL, SQL Server, Oracle — then a concrete, side-by-side tour of 8 real places the exact same query behaves differently across them: pagination, string concatenation, auto-increment keys, identifier quoting, boolean types, LIKE case-sensitivity, upsert syntax, and current date/time — including the classic MySQL || gotcha that changes a query's meaning without ever throwing an error.

18–22 min

The Problem in Real Life

GreenMart finally has a real ops story: backups that get tested, permissions that mean something, ten cities sharded and holding steady. An investor visiting for the day glances at the dashboard and asks the one question nobody on the team had ever actually said out loud: "So what database is all of this actually running on?"

Sarah pauses. Every table, every query, every EXPLAIN QUERY PLAN this whole course has run on SQLite — because it needed no server, no setup, nothing but a browser tab. That was the right choice for learning. It isn't automatically the right answer for a ten-city company processing real checkouts right now, and Sarah realizes she's never actually worked out whether it should be.

S

SQLite got us this far. The real question is whether it should take us the rest of the way.

Sarah

One File on One Machine vs. a Server Everyone Connects To

SQLite was a teaching choice, not the only one

This course ran on SQLite because it needs no server and works instantly in a browser — production systems have real alternatives with real differences.

Embedded vs. client-server changes everything

SQLite runs inside one process, one writer at a time. MySQL, PostgreSQL, SQL Server, and Oracle run as a server many applications connect to at once.

Most SQL transfers — but not word-for-word

Tables, joins, indexes, and transactions carry over everywhere. Specific clauses like pagination, upsert, or string concatenation are written differently — and sometimes silently mean something else entirely.

The most dangerous differences never throw an error

A missing clause fails loudly. A reinterpreted operator, like MySQL's ||, runs fine and just quietly does the wrong thing.

SQLite vs. Real Production Engines

The first real split isn't about SQL at all — it's about how the database actually runs. SQLite is embedded: the whole database is one file, and it runs inside the same process as whatever application opens it, handling one writer at a time. MySQL, PostgreSQL, SQL Server, and Oracle are client-server: a separate server process runs continuously, and many applications connect to it over a network, writing at the same time.

That single difference decides more than it sounds like it should. GreenMart's ten cities each run their own checkout servers, all needing to write orders at the same time, from different machines entirely. That's exactly the kind of concurrent-write problem Act 4 first raised — just at a scale where an embedded, single-writer file genuinely can't keep up with ten separate servers hammering it at once.

One honest note before naming names and comparing syntax: none of what follows is meant to be memorized. Nobody keeps five engines' worth of keywords in their head at once — not even people who use several of them every day. What actually matters is understanding that these differences exist, and roughly which kinds of things tend to differ, so nothing here quietly surprises you later. The exact keyword is always one search away the moment it's actually needed.

  • MySQL — client-server; historically the database behind huge numbers of high-traffic web applications, with multiple storage engines available (most commonly InnoDB)
  • PostgreSQL — client-server; known for strict standards-compliance, advanced features like native JSON columns and window functions, and being easy to extend
  • SQL Server — client-server; Microsoft's database, common wherever the rest of the stack is already Microsoft-centric (.NET, Azure)
  • Oracle — client-server; a long-standing enterprise database, common at large companies with the budget its licensing demands
  • SQLite — embedded; zero setup, the engine behind this entire course, and behind countless real mobile apps, desktop tools, and browsers
Table — Which Engine Actually Fits Which Job
EngineRuns AsWhere It Shows Up
SQLiteEmbedded — one file, one processMobile apps, desktop tools, browsers — and this course's Playground
MySQLClient-serverHigh-traffic web apps; historically the "M" in many popular web stacks
PostgreSQLClient-serverApps that lean on advanced SQL, JSON, or strict data integrity
SQL ServerClient-server.NET and other Microsoft-centric enterprise systems
OracleClient-serverLarge enterprises — banks, telecoms — with the budget for it
This whole line is a row — one record

SQLite isn't "the toy one" — it's a completely legitimate production database for the right job. It's specifically the wrong fit for GreenMart at ten cities, because that job needs a server managing many simultaneous writers, not one file handling them one at a time.

"Skip the first 20 matching orders, then give me the next 10" — SQLite, PostgreSQL, and MySQL all understand LIMIT ... OFFSET ... exactly the same way. This exact query runs unchanged on any of the three.

Pagination — SQLite, PostgreSQL, MySQL
SELECT * FROM Orders
ORDER BY OrderDate DESC
LIMIT 10 OFFSET 20;

MySQL also accepts an older shorthand, LIMIT 20, 10 (offset first, then count) — the same result, different word order, and easy to misread.

SQL Server has no LIMIT keyword at all — copy the query above in verbatim and it fails with a syntax error. The same "skip 20, take 10" idea needs OFFSET ... ROWS FETCH NEXT ... ROWS ONLY instead, and a plain ORDER BY without an OFFSET clause can't be paginated this way at all.

Pagination — SQL Server
SELECT * FROM Orders
ORDER BY OrderDate DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

SQL Server's older, more common habit is SELECT TOP 10 ... — but TOP has no built-in way to skip rows, so it only ever answers "give me the first N," never real page 2, 3, 4.

Oracle 12c and later accept the exact same OFFSET ... FETCH NEXT ... syntax as SQL Server — the two converged on the same standard-SQL wording, even though neither supports LIMIT.

Pagination — Oracle
SELECT * FROM Orders
ORDER BY OrderDate DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

Plenty of Oracle code still running today predates 12c, and instead filters on the pseudo-column ROWNUM (SELECT * FROM (SELECT * FROM Orders ORDER BY OrderDate DESC) WHERE ROWNUM <= 30) — and that alone only gives the first 30 rows; skipping the first 20 needs a second nested layer on top.

SQLite, PostgreSQL, and Oracle all use || to glue text together — this exact expression runs unchanged on any of the three, and it's the actual SQL-standard operator for concatenation.

String concatenation — SQLite, PostgreSQL, Oracle
SELECT FirstName || ' ' || LastName AS FullName FROM Customers;

MySQL treats || as logical OR by default, not concatenation — the exact query above doesn't error out, it just silently evaluates a completely different boolean expression instead. MySQL needs its own CONCAT() function to glue text together.

String concatenation — MySQL
SELECT CONCAT(FirstName, ' ', LastName) AS FullName FROM Customers;

This is one of the most common "it worked everywhere else" surprises in real migrations: no syntax error, no warning — just a query that quietly means something else.

SQL Server concatenates with the + operator — the same one used for addition. That means concatenating a text column with a NULL value returns NULL for the whole expression, silently swallowing everything else in it.

String concatenation — SQL Server
SELECT FirstName + ' ' + LastName AS FullName FROM Customers;

SQL Server 2012+ also has a real CONCAT() function that treats NULL as an empty string instead, avoiding that exact surprise.

In SQLite, an INTEGER PRIMARY KEY column is automatically an alias for the table's internal rowid, and already behaves like an auto-incrementing key with no extra keyword at all.

Auto-incrementing key — SQLite
CREATE TABLE Orders (
OrderID INTEGER PRIMARY KEY,
CustomerID INTEGER NOT NULL
);

Adding AUTOINCREMENT (INTEGER PRIMARY KEY AUTOINCREMENT) guarantees an ID is never reused even after its row is deleted — rarely necessary, but occasionally load-bearing.

MySQL needs the explicit AUTO_INCREMENT keyword on the column. Leave it off, and MySQL doesn't fill OrderID in automatically — every INSERT has to supply its own value by hand.

Auto-incrementing key — MySQL
CREATE TABLE Orders (
OrderID INT AUTO_INCREMENT PRIMARY KEY,
CustomerID INT NOT NULL
);

PostgreSQL and Oracle (12c and later) both settled on the same standard-SQL wording: GENERATED ALWAYS AS IDENTITY. Older code for either one often uses a different shortcut instead — PostgreSQL's SERIAL, or Oracle's separate CREATE SEQUENCE plus a trigger that reads the next value on every insert.

Auto-incrementing key — PostgreSQL & Oracle (12c+)
CREATE TABLE Orders (
OrderID INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
CustomerID INT NOT NULL
);

SQL Server uses IDENTITY(seed, increment) — here, start counting at 1 and increase by 1 with every new row.

Auto-incrementing key — SQL Server
CREATE TABLE Orders (
OrderID INT IDENTITY(1,1) PRIMARY KEY,
CustomerID INT NOT NULL
);

"Order" and "Date" are both SQL reserved words — using them as plain column names would collide with the language itself. SQLite, PostgreSQL, and Oracle all resolve that the same way: wrap the name in double quotes.

Quoting a reserved word as a column name — SQLite, PostgreSQL, Oracle
SELECT "Order", "Date" FROM Orders;

MySQL uses backticks for the exact same purpose. The double-quoted version above isn't a syntax error in MySQL by default — it's usually just interpreted as a plain text string instead of a column name, which fails or returns the wrong thing without ever looking like a typo.

Quoting a reserved word as a column name — MySQL
SELECT `Order`, `Date` FROM Orders;

SQL Server's own convention is square brackets. Three engines, three different characters, for the exact same idea.

Quoting a reserved word as a column name — SQL Server
SELECT [Order], [Date] FROM Orders;

PostgreSQL has a real BOOLEAN type, with genuine TRUE/FALSE literals — the column can only ever hold one of exactly two values (or NULL, if allowed).

Boolean column — PostgreSQL
CREATE TABLE Orders (
OrderID INTEGER PRIMARY KEY,
IsPaid BOOLEAN NOT NULL DEFAULT FALSE
);
SELECT * FROM Orders WHERE IsPaid = TRUE;

MySQL accepts the same BOOLEAN keyword and TRUE/FALSE literals — but only as a convenience. Underneath, BOOLEAN is just an alias for TINYINT(1), and the column actually stores a plain 1 or 0.

Boolean column — MySQL
CREATE TABLE Orders (
OrderID INT PRIMARY KEY,
IsPaid BOOLEAN NOT NULL DEFAULT FALSE
);

SQL Server has no BOOLEAN type or TRUE/FALSE literal at all. The standard substitute is BIT, a column that only ever holds 0 or 1 — the query above has to compare against 1, not TRUE.

Boolean column — SQL Server
CREATE TABLE Orders (
OrderID INT PRIMARY KEY,
IsPaid BIT NOT NULL DEFAULT 0
);
SELECT * FROM Orders WHERE IsPaid = 1;

Oracle SQL has no boolean type at all — not even a BIT-style stand-in. The common workaround is a NUMBER(1) column with a CHECK constraint enforcing only two valid values, representing true/false by convention rather than by an actual type.

Boolean column — Oracle
CREATE TABLE Orders (
OrderID NUMBER PRIMARY KEY,
IsPaid NUMBER(1) NOT NULL CHECK (IsPaid IN (0, 1))
);

PostgreSQL's LIKE is case-sensitive by default — 'priya' and 'Priya' are treated as different text entirely. Reaching for a case-insensitive match means using ILIKE instead, a PostgreSQL-specific extension with no SQL-standard equivalent.

Case-sensitive text matching — PostgreSQL
SELECT * FROM Customers WHERE Name LIKE 'priya';
-- 0 rows: PostgreSQL's LIKE is case-SENSITIVE by default
SELECT * FROM Customers WHERE Name ILIKE 'priya';
-- matches 'Priya': ILIKE is PostgreSQL's case-insensitive version

Act 5's Query Optimization chapter showed the opposite default: SQLite's own LIKE is case-insensitive out of the box, which is exactly why a trailing-wildcard LIKE couldn't safely use a sorted index there. Same keyword, opposite default behavior, depending entirely on which engine is running it.

"Insert this row, but if a customer with this ID already exists, update their balance instead" — SQLite and PostgreSQL both handle it with ON CONFLICT ... DO UPDATE SET ..., referencing the would-be-inserted row through the special excluded alias.

Upsert (insert-or-update) — SQLite & PostgreSQL
INSERT INTO Customers (CustomerID, Name, Balance)
VALUES (501, 'Priya', 140)
ON CONFLICT (CustomerID) DO UPDATE SET Balance = excluded.Balance;

MySQL's own wording for the same idea is ON DUPLICATE KEY UPDATE, referencing the would-be-inserted value through the VALUES() function instead of an excluded alias.

Upsert (insert-or-update) — MySQL
INSERT INTO Customers (CustomerID, Name, Balance)
VALUES (501, 'Priya', 140)
ON DUPLICATE KEY UPDATE Balance = VALUES(Balance);

MySQL 8.0.20+ actually discourages VALUES() here in favor of a row alias (... AS new ON DUPLICATE KEY UPDATE Balance = new.Balance) — both still work, but it's a real sign of how even one engine's own syntax keeps shifting release to release.

SQL Server and Oracle have no ON CONFLICT or ON DUPLICATE KEY shortcut at all — the same "insert or update" idea needs a full MERGE statement: match against a source row, then spell out exactly what happens on a match and what happens without one.

Upsert (insert-or-update) — SQL Server & Oracle
MERGE INTO Customers AS target
USING (SELECT 501 AS CustomerID, 'Priya' AS Name, 140 AS Balance) AS source
ON target.CustomerID = source.CustomerID
WHEN MATCHED THEN UPDATE SET Balance = source.Balance
WHEN NOT MATCHED THEN INSERT (CustomerID, Name, Balance) VALUES (source.CustomerID, source.Name, source.Balance);

Same result as the two snippets above, in roughly four times the words — a good example of how a shortcut convenience in one engine can simply not exist in another.

CURRENT_TIMESTAMP is part of the actual SQL standard, and SQLite, PostgreSQL, and MySQL all honor it, returning the current date and time with no engine-specific syntax at all.

Current date and time — SQLite, PostgreSQL, MySQL
SELECT CURRENT_TIMESTAMP;

Each also has its own shorter, non-standard alias for the same thing: PostgreSQL and MySQL both accept NOW(), and SQLite accepts datetime('now').

SQL Server and Oracle each use their own non-standard function name instead — GETDATE() and SYSDATE respectively — and neither one honors CURRENT_TIMESTAMP the same way the three engines above do.

Current date and time — SQL Server & Oracle
-- SQL Server:
SELECT GETDATE();
-- Oracle:
SELECT SYSDATE FROM DUAL;

Oracle's DUAL is a real quirk worth knowing by name: Oracle requires every SELECT to have a FROM clause, even one with no real table behind it, so DUAL exists purely as a built-in one-row dummy table to make queries like this syntactically valid.

None of this makes SQLite the wrong choice in general — plenty of real, successful software runs on it in production, wherever one application owns its own data on its own machine. It's specifically the wrong fit for GreenMart now, because ten separate sets of checkout servers writing at the same moment need a real client-server database standing between them, managing those connections — not an embedded file built around one writer at a time.

And as the snippets above show, moving to one of them isn't purely a copy-paste job either. Every relational database implements the same core relational model and most of the same SQL — but each one also extends or diverges from it in real, specific, documented ways. The good news is that none of this touches the actual concepts Sarah spent this whole course learning: tables, keys, joins, indexes, transactions, and ACID mean exactly the same thing everywhere. What changes is a comparatively small, learnable set of vendor-specific words for ideas already understood.

Key Takeaway

There's no single "best" database — only the one that fits how many things need to write to it at once, and how complex the queries need to be. The concepts transfer everywhere; it's specific clauses like pagination, upsert, and string concatenation that don't — and the most dangerous ones are the differences that run without ever throwing an error.

Why This Matters

Job postings, interview questions, and real production systems all name a specific engine — MySQL, PostgreSQL, SQL Server, Oracle — not just "SQL" in the abstract. Knowing what actually separates them, instead of only ever having practiced on one, is what turns everything this course taught into something directly usable at a real job — and knowing exactly where a migrated query can silently change meaning, not just fail loudly, is the kind of detail that shows up in real production incidents, not just interview trivia.

GreenMart finally has an honest answer for the investor's question — and so does the reader, for whichever real database they meet next, and for exactly which parts of a query to double-check when they get there. One thing remains before this Act closes: turning everything learned here into GreenMart's actual scaling plan.

Next