My SQL Engine Finally Started Saying “No” | Stage 3
We’re back again.
My SQL Engine Finally Started Saying “No” | Stage 3

We’re back again.
Stage 1 was the Lexer — breaking raw SQL strings into tokens.
Stage 2 was the Parser — taking those tokens and building an AST so the query actually had structure.
And now I’ve reached Stage 3: The Semantic Analyzer.
This is the stage where my SQL engine finally stopped trusting the query blindly.
Because until now, something like this:
SELECT power_level FROM users;
would happily pass through the lexer and parser even if power_level wasn't a real column.
The syntax is valid. The structure is valid.
But the query itself makes absolutely no sense.
That’s exactly what semantic analysis is for.
So What Does A Semantic Analyzer Actually Do?
The semantic analyzer validates the query against the database schema.
Basically:
- does the table exist?
- do the columns exist?
- is the WHERE clause referencing real columns?
This is the first stage where my engine actually compares the query against something real instead of just checking grammar and structure.
And to make that possible, I needed something new: a Catalog.
The Catalog — Giving The Engine Memory
Up until now, my engine had no idea what tables or columns even existed.
The parser just built AST nodes mechanically and moved on.
So I added a tiny in-memory schema registry called the Catalog.
struct ColumnSchema {
std::string col_name;
std::string col_type;
};
struct TableSchema {
std::string table_name;
std::vector<ColumnSchema> columns;
};
struct Catalog {
std::vector<TableSchema> tables;
TableSchema* getTable(std::string name);
};
This is basically the engine’s memory.
It stores:
- table names
- column names
- column types
Now the semantic analyzer can finally validate queries against actual schemas instead of blindly assuming everything exists.
The Semantic Analyzer
One thing I found interesting here was that the analyzer naturally split itself into multiple passes.
At first I tried validating everything in one traversal and it became messy very quickly.
So I separated it into three passes.
Each pass has exactly one job.
Pass 1 — Validate The Table
The analyzer first walks through the AST looking for the table name.
if (FromNode* f = dynamic_cast<FromNode*>(ast_node)) {
table = catalog.getTable(f->table);
if (table == nullptr) {
throw std::runtime_error(
"Error: table '" + f->table + "' does not exist"
);
}
}
So this query:
SELECT name FROM users;
passes.
But this:
SELECT name FROM definitely_not_real;
fails immediately.
This was the first moment where my engine actually started checking queries against reality.
Pass 2 — Validate SELECT Columns
Once the table is validated, the analyzer checks every selected column.
for (const std::string& col : s->columns) {
bool found = false;
for (const ColumnSchema& c : table->columns) {
if (c.col_name == col) {
found = true;
break;
}
}
if (!found) {
throw std::runtime_error(
"Error: column '" + col +
"' does not exist in table '" +
table->table_name + "'"
);
}
}
So this works:
SELECT age FROM users;
But this does not:
SELECT chakra_level FROM users;
Before this stage, column names were just strings sitting inside AST nodes.
Now they actually mean something.
Pass 3 — Validate WHERE Conditions
The third pass validates columns used inside the WHERE clause.
if (WhereNode* w = dynamic_cast<WhereNode*>(ast_node)) {
const std::string& col = w->condition.left;
bool found = false;
for (const ColumnSchema& c : table->columns) {
if (c.col_name == col) {
found = true;
break;
}
}
if (!found) {
throw std::runtime_error(
"Error: column '" + col +
"' does not exist in table '" +
table->table_name + "'"
);
}
}
So this query:
SELECT name FROM users WHERE age > 18;
passes.
But this:
SELECT name FROM users WHERE bank_balance > 9999999;
fails immediately.
The parser already proved the query was structurally correct.
The semantic analyzer proves whether it logically makes sense.
The Bug I Found
And yes. There was another bug.
Take this query:
SELECT name FROM users;
Completely valid query.
But remember the parser issue from Stage 2?
The parser still always attaches a WhereNode even when no WHERE clause exists.
Which means the semantic analyzer still tries validating the WHERE condition even when it’s empty.
So this:
w->condition.left
becomes:
""
An empty string.
And then the analyzer starts searching the schema for a column literally named "".
Which obviously fails.
Meaning my semantic analyzer throws an error… on a perfectly valid query.
That one took me a minute to figure out because at first the semantic analyzer looked correct.
The real issue was inherited from Stage 2.
Different stage. Same bug chain.
The Fix
The fix ended up being pretty simple:
if (w->condition.left.empty()) {
ast_node = ast_node->child;
continue;
}
If the condition is empty, skip validation entirely.
Don’t validate something that doesn’t exist.
One Thing This Project Keeps Teaching Me
Every stage of this project keeps reinforcing the same engineering lesson:
Silent structural problems don’t disappear. They just show up later in worse ways.
Stage 1 had malformed strings quietly passing through the lexer.
Stage 2 had empty WhereNodes silently attached to queries.
And now Stage 3 inherited that exact problem downstream.
Different layer. Same principle.
What’s Next
Stage 4 is the Query Planner.
Right now my engine can:
- tokenize queries
- build structure
- validate semantics
But it still doesn’t know how to execute anything.
The planner is where the query finally starts becoming executable.
That’s where things start getting genuinely database-y.
Same deal as always: building in public, bugs included.
📂 GitHub — https://github.com/zoolpher/sql-engine
🐦 X — https://x.com/aryanmh0
🎥 YouTube — https://youtu.be/geoK6kt073M
Built by zoolpher — B.Tech CS, systems engineering track.
메타데이터
- post_id
- 1ff3df634089
- slug
- my-sql-engine-finally-started-saying-no-stage-3-1ff3df634089
- url
- https://medium.com/@zoolpher/my-sql-engine-finally-started-saying-no-stage-3-1ff3df634089
- canonical_url
- https://medium.com/@zoolpher/my-sql-engine-finally-started-saying-no-stage-3-1ff3df634089
- author_url
- https://medium.com/@zoolpher
- status
- ok
- fetched_at
- 2026-06-09 15:37:30