In this post let’s find out how the data structure look like for a b-tree index in postgresql. Also we analyse our following test query results using EXPLAIN ANALYSE
EXPLAIN ANALYSE SELECT * FROM users WHERE city='Chicago';
QUERY PLAN ----- Seq Scan on users (cost=0.00..2332.00 rows=24780 width=51)
(actual time=0.077..21.085 rows=25000 loops=1)
CREATE INDEX idx_users_city ON users(city);
CREATE INDEX EXPLAIN ANALYSE SELECT * FROM users WHERE city='Chicago';
QUERY PLAN ---- Bitmap Heap Scan on users (cost=280.34..1672.09 rows=24780 width=51)
(actual time=3.309..15.732 rows=25000 loops=1)
Q1) How can PostgreSQL build a B-tree index for emails when every email is unique?
Short answer: Yes. The index contains one entry for every row.
The tree lets PostgreSQL eliminate huge portions of the search space.
Instead of checking every row, it follows the correct branch.
Does it consume memory?
Yes.
Every index consumes disk space.
If you have:
1 million rows
and create an index on email,
the index also has approximately 1 million entries.
That’s why we don’t create indexes on everything.
What happens during INSERT?
Suppose:
INSERT INTO users(email)
VALUES ('zack@test.com');
PostgreSQL does two things:
Inserts the row into the table.
Inserts a new entry into the B-tree.
That’s why indexes make:
INSERT
UPDATE
DELETE
slightly slower.
Interview Question
If an index has one entry per row, isn’t searching still O(n)?
No.
Because of the B-tree.
Searching isn’t done linearly.
It’s approximately:
O(log n)
instead of
O(n)
For:
1,000,000 rows
a B-tree may require only around 20–25 comparisons rather than scanning all million rows.
Q2. Why did PostgreSQL use a Bitmap Heap Scan instead of an Index Scan?
Your output:
Before index:
Seq Scan on users
rows = 25000
After index:
Bitmap Heap Scan
rows = 25000
This is actually exactly what PostgreSQL should do.
Let’s understand why.
Your data distribution
Remember how you inserted the data?
CASE
WHEN i % 4 = 0 THEN 'Boston'
WHEN i % 4 = 1 THEN 'Chicago'
WHEN i % 4 = 2 THEN 'New York'
ELSE 'Dallas'
END
So:
100,000 rows
↓
4 cities
↓
25,000 users per city
That means:
Chicago
↓
25%
of the table
Option 1 — Sequential Scan
Without index:
Read
100000 rows
↓
Return
25000 rows
One pass through the table.
Option 2 — Normal Index Scan
Imagine PostgreSQL used the city index.
It would do something like:
Index
↓
Find row 4
↓
Jump to table
↓
Find row 9
↓
Jump to table
↓
Find row 13
↓
Jump to table
...
25000 times
That’s a lot of random table accesses.
Random disk reads (or random memory accesses) are expensive.
Option 3 — Bitmap Heap Scan
This is PostgreSQL’s compromise.
Step 1:
Read the index.
Chicago
↓
Rows
4
9
13
22
31
...
99998
Instead of fetching the rows immediately, PostgreSQL creates a bitmap.
Conceptually:
Rows to fetch
4
9
13
22
31
...
Then it sorts/groups those row locations by table page.
Only then does it read the table.
So instead of:
Index
↓
Table
↓
Index
↓
Table
↓
Index
↓
Table
it does:
Index
↓
Collect all matching row locations
↓
Read table pages efficiently
↓
Return rows
This reduces random I/O significantly.
When does PostgreSQL choose Bitmap Heap Scan?
Typically when:
Some rows match
but
not too few
and
not almost all.
Think of it like this:
Rows matched
Likely plan
1 row
Index Scan
100 rows
Index Scan
5,000 rows
Bitmap Heap Scan
25,000 rows
Bitmap Heap Scan
99,000 rows
Seq Scan
The exact thresholds depend on statistics and cost estimates.
Why not an Index Scan?
Your query returns:
25,000 rows
That’s 25% of the table.
PostgreSQL thinks:
“Using the index is worthwhile, but fetching 25,000 rows one-by-one would be inefficient. I’ll gather all matching row locations first and then fetch the data in batches.”
That’s why you got:
Bitmap Heap Scan
Understanding our EXPLAIN ANALYZE Output
Seq Scan on users
(cost=0.00..2332.00 rows=24780 width=51)
(actual time=0.077..21.085 rows=25000 loops=1)
Let’s decode it.
Seq Scan
PostgreSQL reads every row.
cost
0.00..2332.00
This is not time.
It’s PostgreSQL’s internal cost estimate.
0.00 = startup cost
2332.00 = estimated total cost
Costs are used only to compare execution plans.
rows=24780
Planner estimated:
24,780 rows
Actual:
25,000 rows
Excellent estimate.
Good statistics help PostgreSQL choose the right plan.
width=51
Average row size is estimated to be:
51 bytes
This helps estimate I/O cost.
actual time
0.077..21.085
First row available after 0.077 ms.
Entire query finished after 21.085 ms.
loops=1
The node executed once.
After Creating the Index
Bitmap Heap Scan
(actual time=3.309..15.732)
Notice:
Execution time dropped from roughly:
21 ms
↓
16 ms
The improvement isn’t dramatic because your query still returns 25% of the table.
Indexes shine when they allow PostgreSQL to skip most of the table.
Want to See an Index Scan?
Try a highly selective query.
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'user75000@example.com';
Since email is unique, PostgreSQL should choose:
Index Scan
because only one row matches.
A Practical Rule for Senior Engineers
When reading an execution plan, ask yourself these questions in order:
How many rows does the query return?
How many rows are in the table?
Is the predicate selective enough for an index?
What scan type did PostgreSQL choose?
Does that choice make sense?
Let’s cover the following topics in the remaining areas of Day 6.
Day 6B – Composite indexes, covering indexes, partial indexes, unique indexes, expression indexes, GIN vs GiST vs BRIN vs Hash indexes, and real-world Rails indexing strategies
Day 6C – Query optimization workshop: we’ll analyze real EXPLAIN ANALYZE outputs together, identify bottlenecks, and optimize queries step by step.
Given our role of Senior Rails Developer, Let’s spend 3 focused sessions on indexing and query optimization will provide much more value than rushing to the next topic.
Today marks an important milestone in this course.
Up until now, we’ve focused on writing correct SQL.
From today onward, we’ll focus on writing fast SQL.
This is one of the biggest differences between a mid-level Rails developer and a senior Rails developer.
A mid-level developer asks:
“Does my query work?”
A senior developer asks:
“How many rows did PostgreSQL have to examine to answer this query?”
Today’s Goals
By the end of today, you should understand:
What an index is
How PostgreSQL uses indexes
B-Tree indexes
Sequential Scan
Index Scan
Bitmap Index Scan
EXPLAIN
EXPLAIN ANALYZE
When indexes help
When indexes hurt
Composite indexes
Foreign key indexes
ActiveRecord index creation
Common interview questions
A Senior Engineer’s Mental Model
Imagine you have a book with 2 million pages.
You need to find:
Ruby on Rails
Without an Index
You start from page 1.
Page 1
Page 2
Page 3
...
Page 2,000,000
This is a Sequential Scan.
With an Index
You open the index section at the back of the book.
Ruby on Rails → Page 1,542,381
Immediately jump there.
This is an Index Scan.
That analogy is almost exactly how database indexes work.
Part 1 – Create a Practice Table
We’ll create a larger dataset than before.
DROPTABLEIFEXISTS users;
CREATETABLE users (
id BIGSERIAL PRIMARYKEY,
name VARCHAR(100),
email VARCHAR(255),
city VARCHAR(100),
age INTEGER,
active BOOLEANDEFAULTtrue
);
Insert Sample Data
Instead of inserting thousands of rows manually, PostgreSQL provides a wonderful function:
generate_series()
We’ll use it a lot.
INSERTINTO users(name, email, city, age)
SELECT
'User '|| i,
'user'|| i ||'@example.com',
CASE
WHEN i %4=0THEN'Boston'
WHEN i %4=1THEN'Chicago'
WHEN i %4=2THEN'New York'
ELSE'Dallas'
END,
20+(i %40)
FROM generate_series(1,100000) i;
Congratulations.
You now have:
100,000 users
Verify:
SELECTCOUNT(*)
FROM users;
Output:
100000
Part 2 – Why Indexes Exist
Suppose we search:
SELECT*
FROM users
WHERE email ='user75000@example.com';
Without an index:
PostgreSQL has to inspect rows one by one.
User 1
No
↓
User 2
No
↓
User 3
No
↓
...
↓
User 75,000
YES
Potentially:
75,000 comparisons
Part 3 – See PostgreSQL’s Plan
Instead of running:
SELECT*
FROM users
WHERE email='user75000@example.com';
Run:
EXPLAIN
SELECT*
FROM users
WHERE email='user75000@example.com';
You will likely see something similar to:
Seq Scan on users
This means:
Sequential Scan
Interview Question:
What is a Sequential Scan?
Answer:
PostgreSQL reads every row (or nearly every row) in the table to evaluate the query condition.
Part 4 – Create an Index
Let’s create one.
CREATE INDEX idx_users_email
ON users(email);
Run:
EXPLAIN
SELECT*
FROM users
WHERE email='user75000@example.com';
Now you’ll likely see:
Index Scan
Congratulations!
You just made your first query significantly faster.
What Is an Index?
An index is a separate data structure maintained by PostgreSQL.
Conceptually:
users table
id
name
email
...
↓
Index
email
↓
Row Location
Notice:
The table itself is not sorted.
The index is.
Part 5 – B-Tree Index
The default PostgreSQL index type is:
B-tree
Rails interviewers love asking:
What type of index does PostgreSQL create by default?
Answer:
B-tree
Good for:
=
<
BETWEEN
ORDER BY
Create explicitly:
CREATE INDEX idx_users_age
ON users
USING btree(age);
Usually:
CREATE INDEX idx_users_age
ON users(age);
creates the same thing.
Part 6 – EXPLAIN ANALYZE
Very important.
Difference:
EXPLAIN
Shows what PostgreSQL plans to do.
EXPLAIN ANALYZE
Actually runs the query and measures it.
Run:
EXPLAIN ANALYZE
SELECT*
FROM users
WHERE email='user75000@example.com';
Output looks similar to:
Index Scan
Planning Time: 0.2 ms
Execution Time: 0.1 ms
Notice:
Planning time
vs
Execution time.
Reading EXPLAIN ANALYZE
Typical output:
Index Scan using idx_users_email
(cost=0.42..8.44)
(rows=1)
(width=65)
(actual time=0.03..0.04)
(actual rows=1)
Don’t panic.
We’ll learn each part.
rows
Estimated rows.
Example:
rows=1
Planner expects:
1 row
actual rows
Returned rows.
actual rows=1
Perfect.
If estimates differ greatly from actual rows, PostgreSQL may choose a poor plan.
This is one reason why running ANALYZE (to refresh table statistics) matters.
Part 7 – Why Doesn’t PostgreSQL Always Use an Index?
This surprises many developers.
Suppose:
SELECT*
FROM users;
Would an index help?
No.
You need every row.
Sequential Scan is faster.
Suppose:
SELECT*
FROM users
WHERE active=true;
Imagine:
98%
of users
are active.
Would using an index help?
Usually not.
Why?
Using the index would require PostgreSQL to:
traverse the index
then visit almost every table row anyway
Sometimes a Sequential Scan is cheaper.
Interview Question:
Why might PostgreSQL ignore an index?
Good answer:
Because the planner estimates that scanning the entire table is cheaper than using the index, often due to low selectivity or because a large percentage of rows match the condition.
Part 8 – Selectivity
A crucial concept.
Imagine:
Gender
Male
Female
Only two values.
Index?
Not very useful.
Now:
email
Every row unique.
Excellent index.
Rule of thumb:
Higher uniqueness
↓
Better selectivity
↓
More useful index
Examples:
Good:
email
UUID
order_number
tracking_number
Poor:
gender
active
status (if only a few values)
Part 9 – Composite Indexes
Suppose we often search:
SELECT*
FROM users
WHERE city='Chicago'
AND age=25;
Instead of:
CREATE INDEX idx_city;
CREATE INDEX idx_age;
We can create:
CREATE INDEX idx_city_age
ON users(city, age);
Interview Question:
Will this index help?
WHERE city='Chicago'
Yes.
Will it help?
WHERE city='Chicago'
AND age=25
Yes.
Will it help?
WHERE age=25
Usually No.
This is called the Leftmost Prefix Rule.
Leftmost Prefix Rule
For an index:
(city, age)
Efficient for:
WHERE city='Chicago'
and
WHERE city='Chicago'
AND age=25
Not generally for:
WHERE age=25
because the index is ordered by city first.
Part 10 – Indexes on Foreign Keys
Consider:
orders
user_id
Rails creates:
belongs_to:user
You often query:
SELECT*
FROM orders
WHERE user_id=5;
Should user_id be indexed?
Absolutely.
Without it:
Every order
↓
Scan
With it:
Jump directly to user 5's orders.
Rails Migration
add_reference:orders,
:user,
foreign_key:true,
index:true
or
t.references:user,
foreign_key:true
Rails creates the index automatically.
Part 11 – Rails Examples
Find by email:
User.find_by(email:email)
Should email be indexed?
Yes.
Authentication:
User.find_by(email:params[:email])
Index?
Definitely.
Showing a user’s orders:
user.orders
Queries:
WHERE user_id=?
Index?
Yes.
Searching by created_at:
Order.order(created_at::desc)
Index?
Often yes, especially for recent-record queries or pagination.
Part 12 – Bitmap Index Scan
Sometimes PostgreSQL combines indexes.
Example:
city='Chicago'
AND
age=30
Two separate indexes:
idx_city
idx_age
Planner may choose:
Bitmap Index Scan
Meaning:
scan both indexes
combine the matching row locations
visit the table once
This can be efficient when no suitable composite index exists.
Today we move from basic filtering and aggregation into query composition.
This is an important topic for senior Rails interviews because many real production queries can be expressed in several ways:
JOIN
IN
EXISTS
Subquery
ActiveRecord association queries
A senior engineer should know not only how to write them, but also:
Which query expresses the business requirement most clearly?
Does the query preserve duplicates?
Does NULL affect the result?
Does PostgreSQL need to calculate one value or evaluate rows repeatedly?
What SQL is ActiveRecord generating?
We’ll build on the users and orders tables from Day 4.
Today’s Goals
By the end of Day 5, you should understand:
What a subquery is
Scalar subqueries
Multi-row subqueries
Subqueries in WHERE
Subqueries in FROM
Correlated subqueries
IN
EXISTS
NOT EXISTS
NOT IN and the NULL trap
ANY
ALL
JOIN vs IN vs EXISTS
ActiveRecord equivalents
Common mistakes
Senior interview questions
Part 1 – Prepare the Practice Data
We’ll use the same domain from Day 4, but add a few more rows to make today’s queries more interesting.
First, inspect your current data:
SELECT*FROM users ORDERBY id;
SELECT*FROM orders ORDERBY id;
If you want to recreate everything from scratch, run:
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
city VARCHAR(100)
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
);
Insert users:
INSERTINTO users(name, city)
VALUES
('John','New York'),
('Mary','Chicago'),
('Bob','Chicago'),
('Alice','Boston'),
('David','Mumbai'),
('Sara','Boston');
Insert orders:
INSERTINTO orders(user_id, amount, status)
VALUES
(1,100,'completed'),
(1,250,'completed'),
(1,75,'pending'),
(2,500,'completed'),
(2,300,'completed'),
(3,200,'pending'),
(4,800,'completed'),
(4,150,'cancelled'),
(6,1000,'completed'),
(6,1200,'completed');
Now our data looks conceptually like this:
User
Orders
John
100, 250, 75
Mary
500, 300
Bob
200
Alice
800, 150
David
No orders
Sara
1000, 1200
This dataset is intentionally designed so we can practice:
users with orders
users without orders
users above average spending
users with expensive orders
correlated subqueries
EXISTS and NOT EXISTS
Part 2 – What Is a Subquery?
A subquery is a query nested inside another SQL statement.
Example:
SELECT*
FROM orders
WHERE amount >(
SELECT AVG(amount)
FROM orders
);
The inner query is:
SELECT AVG(amount)
FROM orders;
The outer query is:
SELECT*
FROM orders
WHERE amount >(...);
Conceptually:
Inner query
↓
Calculate average order amount
↓
Return the result
↓
Outer query
↓
Find orders greater than that value
Let’s run the inner query separately first:
SELECT AVG(amount)
FROM orders;
Total amount:
4575
Number of orders:
10
Average:
457.5
Now the outer query becomes conceptually:
SELECT*
FROM orders
WHERE amount >457.5;
Result:
500
800
1000
1200
Rails Equivalent
Order.where(
"amount > (?)",
Order.select("AVG(amount)")
)
However, in Rails you may also see:
average=Order.average(:amount)
Order.where("amount > ?", average)
These are not exactly the same approach.
The first can produce one SQL statement containing a subquery.
The second executes:
Query 1 → Calculate average
Query 2 → Find orders above average
That distinction can matter when data changes between queries and when minimizing database round trips.
Part 3 – Scalar Subqueries
A scalar subquery returns:
One row
One column
Therefore, it produces a single value.
Example:
SELECT AVG(amount)
FROM orders;
Result:
457.5
We can use that result with operators such as:
=
>
<
>=
<=
<>
Example:
SELECT
id,
user_id,
amount
FROM orders
WHERE amount >(
SELECT AVG(amount)
FROM orders
);
What Happens if the Subquery Returns Multiple Rows?
Try:
SELECT*
FROM orders
WHERE amount =(
SELECT amount
FROM orders
);
The inner query returns many rows.
PostgreSQL will raise an error similar to:
more than one row returned by a subquery used as an expression
Why?
Because:
amount = ???
expects one value.
But the subquery returned:
100
250
75
500
300
...
PostgreSQL cannot compare one amount against multiple scalar values using =.
This leads us to IN.
Part 4 – IN with a Subquery
Suppose the requirement is:
Find users who have placed at least one order.
We can write:
SELECT*
FROM users
WHERE id IN(
SELECT user_id
FROM orders
);
Run the subquery separately:
SELECT user_id
FROM orders;
Result:
1
1
1
2
2
3
4
4
6
6
Conceptually:
SELECT*
FROM users
WHERE id IN(1,1,1,2,2,3,4,4,6,6);
Result:
John
Mary
Bob
Alice
Sara
David is excluded because he has no orders.
Rails Equivalent
A good ActiveRecord version is:
User.where(
id:Order.select(:user_id)
)
Conceptually, Rails can generate:
SELECT*
FROM users
WHERE id IN(
SELECT user_id
FROM orders
);
Notice the difference between:
Order.select(:user_id)
and:
Order.pluck(:user_id)
This is important.
Using select
User.where(id:Order.select(:user_id))
can remain a single SQL statement with a subquery.
Using pluck
User.where(id:Order.pluck(:user_id))
executes the inner query immediately.
Conceptually:
Query 1
SELECT user_id FROM orders;
Then Rails constructs another query:
Query 2
SELECT *
FROM users
WHERE id IN (1, 1, 1, 2, 2, 3, ...);
For a large dataset, that can be undesirable.
Senior-Level Insight
When building SQL subqueries in ActiveRecord, don’t automatically reach for pluck.
Ask:
Do I want Ruby to materialize these IDs?
Or:
Can PostgreSQL keep the work inside one SQL statement?
Part 5 – NOT IN
Suppose the requirement is:
Find users who have never placed an order.
You might write:
SELECT*
FROM users
WHERE id NOTIN(
SELECT user_id
FROM orders
);
Result:
David
With our current schema, this works because:
orders.user_id BIGINT NOTNULL
Therefore, the subquery cannot return NULL.
But NOT IN has a famous SQL trap.
Part 6 – The NOT IN + NULL Trap
Let’s create a small demonstration table.
DROPTABLEIFEXISTS order_users_demo;
CREATETABLE order_users_demo (
user_id BIGINT
);
Insert:
INSERTINTO order_users_demo(user_id)
VALUES
(1),
(2),
(NULL);
Now run:
SELECT*
FROM users
WHERE id NOTIN(
SELECT user_id
FROM order_users_demo
);
You might expect:
Bob
Alice
David
Sara
But you get:
0 rows
Why?
Because SQL uses three-valued logic:
TRUE
FALSE
UNKNOWN
Conceptually:
id NOTIN(1,2,NULL)
behaves like:
id <>1
AND id <>2
AND id <>NULL
But:
id <>NULL
is not TRUE.
It is:
UNKNOWN
And:
TRUE AND TRUE AND UNKNOWN
results in:
UNKNOWN
WHERE only keeps rows where the condition evaluates to TRUE.
This is one of the most important SQL interview traps to remember.
Part 7 – EXISTS
Now let’s solve:
Find users who have at least one order.
Using EXISTS:
SELECT*
FROM users u
WHEREEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
);
Result:
John
Mary
Bob
Alice
Sara
David is excluded.
How Does EXISTS Work?
Look carefully:
SELECT*
FROM users u
WHEREEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
);
The inner query references:
u.id
But u was defined in the outer query.
Therefore, this is a:
Correlated Subquery
Conceptually PostgreSQL evaluates:
John
↓
Does an order exist with user_id = John's id?
↓
Yes
↓
Keep John
Then:
Mary
↓
Does an order exist?
↓
Yes
↓
Keep Mary
Then:
David
↓
Does an order exist?
↓
No
↓
Remove David
Important: this is a useful conceptual model, but it does not mean PostgreSQL must literally execute the inner query once per outer row. The optimizer can transform correlated EXISTS queries into efficient semi-join plans.
We’ll inspect that later using:
EXPLAIN ANALYZE
Why SELECT 1?
You commonly see:
EXISTS(
SELECT1
FROM orders
...
)
Why 1?
Because EXISTS doesn’t care what columns are returned.
It only asks:
Does at least one matching row exist?
These are semantically equivalent:
EXISTS(
SELECT1
FROM orders
WHERE ...
)
EXISTS(
SELECT*
FROM orders
WHERE ...
)
EXISTS(
SELECT amount
FROM orders
WHERE ...
)
SELECT 1 communicates intent clearly.
Part 8 – NOT EXISTS
Requirement:
Find users who have never placed an order.
SELECT*
FROM users u
WHERENOTEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
);
Result:
David
This is called an anti-join pattern.
Conceptually:
For each user:
Does a matching order exist?
YES → reject
NO → keep
Compare With LEFT JOIN
We learned this yesterday:
SELECT u.*
FROM users u
LEFTJOIN orders o
ON o.user_id = u.id
WHERE o.id ISNULL;
And today:
SELECT u.*
FROM users u
WHERENOTEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
);
Both express:
Find users without orders.
The PostgreSQL optimizer may produce similar execution strategies.
However, NOT EXISTS often expresses the business requirement more directly:
Keep the user if no matching order exists.
Rails Equivalent: where.missing
Rails provides a very readable API:
User.where.missing(:orders)
Conceptually, Rails generates a LEFT OUTER JOIN with an IS NULL condition.
Another option is to build a NOT EXISTS query using Arel, but for standard Rails association queries, where.missing is usually clearer.
Rails Equivalent: where.associated
Find users who have orders:
User.where.associated(:orders)
Depending on Rails version and query construction, this uses an association join and filters out missing related rows.
You may also write:
User.joins(:orders).distinct
Remember why distinct can be needed:
John has 3 orders
JOIN result:
John
John
John
EXISTS does not duplicate John because it tests existence rather than returning matching order rows.
This is a major conceptual difference.
Part 9 – IN vs EXISTS
Let’s compare them.
IN
SELECT*
FROM users
WHERE id IN(
SELECT user_id
FROM orders
);
Conceptually:
Is this user’s ID present in the set of order user IDs?
EXISTS
SELECT*
FROM users u
WHEREEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
);
Conceptually:
Does at least one matching order exist for this user?
Which Is Faster?
A common junior-level answer is:
EXISTS is always faster.
That’s incorrect.
Modern PostgreSQL can rewrite IN and EXISTS into similar plans, such as semi-joins.
Performance depends on:
table sizes
indexes
statistics
data distribution
selectivity
query structure
PostgreSQL planner decisions
The correct senior-level approach is:
Choose the query that expresses the requirement clearly, then inspect the execution plan when performance matters.
Later we’ll compare:
EXPLAIN ANALYZE
SELECT ...
WHERE id IN(...);
with:
EXPLAIN ANALYZE
SELECT ...
WHEREEXISTS(...);
Part 10 – Correlated Subqueries
We’ve already seen one:
SELECT*
FROM users u
WHEREEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
);
The inner query depends on the outer query.
Let’s look at another important example.
Requirement:
Find orders whose amount is greater than the average order amount for that particular user.
This is different from:
Find orders above the global average.
Global average:
SELECT*
FROM orders
WHERE amount >(
SELECT AVG(amount)
FROM orders
);
Per-user average:
SELECT
o.id,
o.user_id,
o.amount
FROM orders o
WHERE o.amount >(
SELECT AVG(o2.amount)
FROM orders o2
WHERE o2.user_id = o.user_id
);
Notice:
o2.user_id = o.user_id
The inner query references the outer row.
Let’s manually reason through John.
John’s orders:
100
250
75
Average:
141.67
Which John’s orders are above John’s average?
250
Mary:
500
300
Average:
400
Above average:
500
Alice:
800
150
Average:
475
Above average:
800
Sara:
1000
1200
Average:
1100
Above average:
1200
Bob has only one order:
200
Average:
200
Condition:
200 > 200
False.
So Bob has no matching result.
Rails Equivalent
A direct SQL fragment is often the clearest ActiveRecord solution:
Order.where(<<~SQL)
orders.amount> (
SELECTAVG(o2.amount)
FROMorderso2
WHEREo2.user_id=orders.user_id
)
SQL
Senior Rails Insight:
Not every query should be forced into a chain of ActiveRecord methods.
For complex database logic, readable SQL inside ActiveRecord can be better than complicated Arel code.
The important things are:
parameterize external input
keep the SQL understandable
test it
inspect its execution plan when needed
Part 11 – Subqueries in FROM
A subquery can also act like a temporary result set.
Requirement:
Calculate each user’s total spending, then return only users whose total spending exceeds 500.
First calculate totals:
SELECT
user_id,
SUM(amount)AS total_spent
FROM orders
GROUPBY user_id;
Now use that result as a derived table:
SELECT*
FROM(
SELECT
user_id,
SUM(amount)AS total_spent
FROM orders
GROUPBY user_id
) user_totals
WHERE total_spent >500;
Important:
PostgreSQL requires an alias for the derived table:
user_totals
Conceptually:
orders
↓
GROUP BY user_id
↓
temporary result set
user_id | total_spent
↓
filter temporary result
↓
total_spent > 500
Of course, for this particular query, HAVING is simpler:
SELECT
user_id,
SUM(amount)AS total_spent
FROM orders
GROUPBY user_id
HAVING SUM(amount)>500;
So why learn subqueries in FROM?
Because derived tables become useful when:
aggregating in multiple stages
joining against aggregated results
ranking data
reporting queries
building complex analytical queries
Part 12 – ANY
ANY compares a value against values returned by a subquery.
Example:
SELECT*
FROM orders
WHERE amount >ANY(
SELECT amount
FROM orders
WHERE user_id =1
);
John’s order amounts:
100
250
75
The condition is:
amount > ANY (100, 250, 75)
This means:
The amount must be greater than at least one value.
Effectively:
amount > 75
because being greater than the smallest value is enough to satisfy the condition.
Therefore:
>ANY
can often be thought of as:
Greater than at least one value
Part 13 – ALL
Now:
SELECT*
FROM orders
WHERE amount >ALL(
SELECT amount
FROM orders
WHERE user_id =1
);
John’s amounts:
100
250
75
Condition:
amount > ALL (100, 250, 75)
The amount must be greater than every value.
Effectively:
amount > 250
Therefore:
>ALL
means:
Greater than every value returned by the subquery.
Important ANY / ALL Mental Model
Given:
10
20
30
Then:
value>ANY(10,20,30)
means:
value > at least one of them
Equivalent threshold:
value > 10
But:
value>ALL(10,20,30)
means:
value > every one of them
Equivalent threshold:
value > 30
Be careful: this shortcut depends on the comparison operator. For example, < ANY and < ALL have different effective thresholds.
Part 14 – ANY with ActiveRecord Arrays
You may occasionally see PostgreSQL queries like:
SELECT*
FROM users
WHERE id =ANY(ARRAY[1,2,3]);
However, normal Rails code would usually use:
User.where(id: [1, 2, 3])
which generates an IN condition.
Don’t use PostgreSQL-specific syntax unless it provides a real advantage.
Part 15 – JOIN vs IN vs EXISTS
Requirement:
Find users who have completed orders.
JOIN
SELECTDISTINCT u.*
FROM users u
JOIN orders o
ON o.user_id = u.id
WHERE o.status ='completed';
Potential issue:
The join produces one row per matching order.
Therefore, duplicates may occur.
We use:
DISTINCT
IN
SELECT*
FROM users
WHERE id IN(
SELECT user_id
FROM orders
WHERE status ='completed'
);
No duplicate users in the outer result.
EXISTS
SELECT*
FROM users u
WHEREEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
AND o.status ='completed'
);
Also no duplicate users.
How Should You Choose?
Use JOIN when:
You need columns from both tables.
You need to aggregate related rows.
You intentionally need matching rows.
Use EXISTS when:
You're asking whether a related row exists.
You don't need columns from the related table.
You want existence semantics without row multiplication.
Use IN when:
You're checking membership in a set of values.
The query reads naturally as "value belongs to this result set."
Do not choose solely based on old rules such as:
EXISTS is always faster than IN.
PostgreSQL’s optimizer is smarter than that.
Part 16 – Practical PostgreSQL Exercises
Let’s practice one query at a time.
Exercise 1
Find all orders above the global average order amount.
SELECT*
FROM orders
WHERE amount >(
SELECT AVG(amount)
FROM orders
);
Rails:
Order.where(
"amount > (?)",
Order.select("AVG(amount)")
)
Exercise 2
Find users who have orders.
SQL using IN:
SELECT*
FROM users
WHERE id IN(
SELECT user_id
FROM orders
);
Rails:
User.where(id:Order.select(:user_id))
Exercise 3
Find users who have orders using EXISTS.
SELECT*
FROM users u
WHEREEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
);
Rails association-oriented alternative:
User.where.associated(:orders)
or:
User.joins(:orders).distinct
Exercise 4
Find users without orders.
SELECT*
FROM users u
WHERENOTEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
);
Rails:
User.where.missing(:orders)
Exercise 5
Find users who have at least one completed order greater than 400.
SELECT*
FROM users u
WHEREEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
AND o.status ='completed'
AND o.amount >400
);
Try writing the ActiveRecord version yourself before looking below.
One option:
User
.joins(:orders)
.where(orders: { status:"completed" })
.where("orders.amount > ?", 400)
.distinct
Exercise 6
Find orders above the average order amount for that order’s user.
SELECT*
FROM orders o
WHERE o.amount >(
SELECT AVG(o2.amount)
FROM orders o2
WHERE o2.user_id = o.user_id
);
This is today’s most important correlated subquery exercise.
Run it and manually verify every returned row.
Part 17 – Common Mistakes
Mistake 1: Using = with a Multi-Row Subquery
Wrong:
WHERE id =(
SELECT user_id
FROM orders
)
If multiple rows are returned, PostgreSQL raises an error.
Use:
IN
or:
EXISTS
depending on the requirement.
Mistake 2: Using NOT IN Without Considering NULL
Potentially dangerous:
WHERE id NOTIN(
SELECT user_id
FROM some_table
)
If the subquery can return NULL, the result may surprise you.
Safer existence-oriented query:
WHERENOTEXISTS(...)
Mistake 3: Using pluck When You Want a SQL Subquery
Potentially inefficient:
User.where(id:Order.pluck(:user_id))
Better:
User.where(id:Order.select(:user_id))
when you want PostgreSQL to handle the operation as a subquery.
Mistake 4: Using JOIN + DISTINCT for Every Existence Check
User.joins(:orders).distinct
works.
But if your requirement is simply:
Does a matching row exist?
EXISTS more directly expresses the requirement.
Mistake 5: Assuming a Correlated Subquery Always Executes Once Per Row
Conceptually, we reason about it that way.
Physically, PostgreSQL may optimize it into:
Semi Join
Anti Join
Hash Join
Nested Loop
other execution strategies
Always distinguish:
SQL semantics
from:
physical execution plan
This distinction is very important for senior-level interviews.
Part 18 – Senior Interview Questions
Try answering these without looking back.
Q1
What is the difference between a normal subquery and a correlated subquery?
Q2
What happens if a scalar subquery returns multiple rows?
Q3
What’s the difference between:
Order.select(:user_id)
and:
Order.pluck(:user_id)
when used to build another query?
Q4
Why can NOT IN return zero rows when the subquery contains NULL?
Q5
What’s the difference between:
JOIN
and:
EXISTS
when one user has many matching orders?
Q6
Is EXISTS always faster than IN in PostgreSQL?
Q7
What is a semi-join?
Q8
What is an anti-join?
Q9
When would you use a subquery in the FROM clause instead of HAVING?
Q10
What is the difference between:
>ANY
and:
>ALL
Part 19 – Today’s Interview Challenge
Do not run this immediately.
First predict the result.
SELECT
u.id,
u.name
FROM users u
WHEREEXISTS(
SELECT1
FROM orders o
WHERE o.user_id = u.id
AND o.amount >(
SELECT AVG(o2.amount)
FROM orders o2
WHERE o2.user_id = u.id
)
);
Questions:
What does the innermost query calculate?
Is the innermost query correlated?
What does the middle EXISTS query check?
Which users will be returned?
Will a user with exactly one order be returned?
Why does this query not need DISTINCT?
Try to manually execute it for:
John
Mary
Bob
Alice
David
Sara
If you can reason through this query confidently, your understanding of SQL has moved beyond basic CRUD querying.
Homework
Use the users and orders tables.
Write both SQL and ActiveRecord for each exercise.
Find users who have at least one completed order.
Find users who have no completed orders.
Find orders greater than the global average order amount.
Find orders greater than the average order amount for their respective user.
Find users whose total order amount is greater than the average total spending across all users who have orders.
Find users who have an order greater than every order placed by John. Use ALL.
Find users who have an order greater than at least one order placed by Sara. Use ANY.
Rewrite “users without orders” using:
LEFT JOIN
NOT EXISTS
NOT IN
Then explain the NULL behavior of each approach.
Write a query using a subquery in FROM to calculate user totals, then join the derived table with users to display:
user name
total spent
Use EXPLAIN ANALYZE to compare:
IN
versus:
EXISTS
for finding users with orders.
Don’t worry if you can’t interpret the complete execution plan yet. Save the output – we’ll learn how to read it systematically.
Day 6 Preview
On Day 6, we’ll cover Indexes and EXPLAIN ANALYZE.
This is one of the most important transitions in the course because we’ll move from:
“Can I write the correct query?”
to:
“Can I explain why this query is fast or slow?”
We’ll cover:
How PostgreSQL stores tables and indexes conceptually
B-tree indexes
Single-column indexes
Composite indexes
Index selectivity
Sequential Scan
Index Scan
Bitmap Index Scan
EXPLAIN
EXPLAIN ANALYZE
Why PostgreSQL sometimes ignores an index
Indexes for foreign keys
Rails migrations for indexes
Query optimization interview questions
For a senior Rails interview, Day 6 is one of the highest-value lessons in the entire course.
If JOINs tell you how records are related, then GROUP BY tells you how to summarize data.
This is one of the highest-frequency SQL topics in senior Ruby on Rails interviews because reporting, dashboards, analytics, and business metrics all rely on it.
As a Rails developer, we’ve probably written things like:
Order.count
or
Order.sum(:amount)
or
User.group(:city).count
Today we’ll learn what PostgreSQL is actually doing under the hood.
Today’s Goals
By the end of today, you’ll understand:
Aggregate Functions
COUNT
SUM
AVG
MIN
MAX
GROUP BY
HAVING
GROUP BY with JOINs
ActiveRecord equivalents
Common interview questions
Senior-level insights
Step 1: Create Our Practice Database
We’ll use two tables.
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
city VARCHAR(100)
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
amount NUMERIC(10,2),
status VARCHAR(20),
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
);
Always think about the business question first. Ask yourself:
“What am I grouping by?”
“What summary do I want for each group?”
JOIN + GROUP BY is the foundation of almost every reporting query you’ll write.
Practical Exercises
Count the total number of users.
Calculate the total revenue from completed orders only.
Find the average order amount for each user.
Count how many orders each status has.
Find users who have placed more than two orders.
Show each city and the total number of users in that city.
Add another user with no orders and show them with a total spend of 0.
Find the highest order amount for every user.
Homework
Create two new tables:
departments
id
name
employees
id
department_id
salary
Insert data for at least:
3 departments
10 employees
Then write SQL to answer:
Number of employees per department.
Average salary per department.
Highest salary per department.
Departments with more than 3 employees.
Departments with no employees (hint: LEFT JOIN + GROUP BY).
Also write the Rails/ActiveRecord equivalent for each query.
Interview Challenge (Don’t Run It Yet)
Without executing the query, tell me what you think this returns:
SELECT
status,
COUNT(*)AS order_count,
SUM(amount)AS total_amount,
AVG(amount)AS average_amount
FROM orders
WHERE amount >=100
GROUPBY status
HAVINGCOUNT(*)>=2
ORDERBY total_amount DESC;
In senior interviews, you’re often asked to reason about a query rather than just write it. Being able to mentally execute SQL like this is a valuable skill.
Day 5 Preview
Tomorrow we’ll move to another core interview topic:
Filtering, Subqueries & EXISTS
We’ll cover:
Scalar subqueries
Correlated subqueries
IN vs EXISTS
NOT EXISTS
ANY and ALL
Common Rails equivalents
Performance considerations
Real interview questions and optimization tips
These concepts are widely used in production applications and are common in senior backend interviews.
If I had to choose one SQL topic that appears most frequently in Senior Developer interviews, it would be:
JOINs
Most Rails developers know:
User.joins(:orders)
But many cannot explain:
What SQL Rails generates
How PostgreSQL executes it
Why duplicates occur
When to use joins
When to use includes
When JOINs become slow
A senior engineer should be comfortable with all of these.
Today’s Goals
By the end of Day 3, you’ll understand:
INNER JOIN
LEFT JOIN
RIGHT JOIN
FULL OUTER JOIN
CROSS JOIN
Self JOIN
How Rails translates associations into JOINs
N+1 query problem
joins vs includes
Interview questions
Step 1: Create Fresh Tables
Let’s create a simple system.
Users
DROPTABLEIFEXISTS orders;
DROPTABLEIFEXISTS users;
CREATETABLE users (
id BIGSERIAL PRIMARYKEY,
name VARCHAR(100)
);
Orders
CREATETABLE orders (
id BIGSERIAL PRIMARYKEY,
user_id BIGINT NOTNULL,
amount NUMERIC(10,2),
CONSTRAINT fk_orders_user
FOREIGNKEY(user_id)
REFERENCES users(id)
);
Insert Sample Data
Users:
INSERTINTO users(name)
VALUES
('John'),
('Mary'),
('Bob'),
('Alice');
Orders:
INSERTINTO orders(user_id, amount)
VALUES
(1,100),
(1,200),
(2,300),
(2,400),
(2,500);
Current data:
users
id
name
1
John
2
Mary
3
Bob
4
Alice
orders
id
user_id
amount
1
1
100
2
1
200
3
2
300
4
2
400
5
2
500
Notice:
Bob has no orders
Alice has no orders
This becomes important.
What is a JOIN?
A JOIN combines rows from multiple tables.
Think:
users
+
orders
=
business information
The database uses a common column:
users.id
=
orders.user_id
1. INNER JOIN
Most common JOIN.
Returns only matching rows.
Query
SELECT
users.id,
users.name,
orders.amount
FROM users
INNERJOIN orders
ON users.id = orders.user_id;
Result:
name
amount
John
100
John
200
Mary
300
Mary
400
Mary
500
Notice:
Bob disappeared
Alice disappeared
Why?
Because they have no matching order.
Visual
users orders
John <-> 100
John <-> 200
Mary <-> 300
Mary <-> 400
Mary <-> 500
Bob X
Alice X
Only matches survive.
Rails Equivalent
User.joins(:orders)
Generated SQL:
SELECT users.*
FROM users
INNERJOIN orders
ON orders.user_id = users.id;
Interview Question
What type of JOIN does Rails joins use?
Answer:
INNER JOIN
Many candidates miss this.
2. LEFT JOIN
Returns:
All rows from LEFT table
+
matching rows from RIGHT table
Query
SELECT
users.name,
orders.amount
FROM users
LEFTJOIN orders
ON users.id = orders.user_id;
Result:
name
amount
John
100
John
200
Mary
300
Mary
400
Mary
500
Bob
NULL
Alice
NULL
Notice:
Bob exists
Alice exists
Even without orders.
Visual
LEFT TABLE = users
Keep everything
John -> order
Mary -> order
Bob -> NULL
Alice -> NULL
Rails Equivalent
User.left_joins(:orders)
Generated SQL:
LEFTOUTERJOIN
Practical Example
Find users without orders.
SELECT users.*
FROM users
LEFTJOIN orders
ON users.id = orders.user_id
WHERE orders.id ISNULL;
Result:
Bob
Alice
Rails:
User.left_joins(:orders)
.where(orders: { id:nil })
Common Interview Question
Find customers who never placed an order.
Expected answer:
LEFTJOIN
+
ISNULL
3. RIGHT JOIN
Opposite of LEFT JOIN.
Keep all rows from right table.
SELECT*
FROM users
RIGHTJOIN orders
ON users.id = orders.user_id;
In real-world Rails projects:
Rarely used
Most engineers rewrite it as LEFT JOIN.
4. FULL OUTER JOIN
Keep everything.
SELECT*
FROM users
FULLOUTERJOIN orders
ON users.id = orders.user_id;
Returns:
All users
+
All orders
matched where possible.
Used occasionally for:
reporting
analytics
reconciliation
Rare in Rails applications.
5. CROSS JOIN
Creates every possible combination.
Example:
CREATETABLE colors (
color VARCHAR(20)
);
INSERTINTO colors
VALUES('Red'),('Blue');
Sizes:
CREATETABLE sizes (
sizeVARCHAR(20)
);
INSERTINTO sizes
VALUES('S'),('M');
Query:
SELECT*
FROM colors
CROSSJOIN sizes;
Result:
Red S
Red M
Blue S
Blue M
Every row paired with every row.
Formula:
RowsA × RowsB
Interview Question:
10 rows × 100 rows
How many rows?
Answer:
1000
6. Self JOIN
A table joins itself.
Very common interview topic.
Create employees:
CREATETABLE employees (
id BIGSERIAL PRIMARYKEY,
name VARCHAR(100),
manager_id BIGINT
);
Insert:
INSERTINTO employees
(name, manager_id)
VALUES
('CEO',NULL),
('Manager1',1),
('Manager2',1),
('Developer1',2),
('Developer2',2);
Query:
SELECT
e.name AS employee,
m.name AS manager
FROM employees e
LEFTJOIN employees m
ON e.manager_id = m.id;
Result:
employee
manager
CEO
NULL
Manager1
CEO
Developer1
Manager1
Rails
classEmployee<ApplicationRecord
belongs_to:manager,
class_name:"Employee",
optional:true
has_many:subordinates,
class_name:"Employee",
foreign_key::manager_id
end
Why Duplicates Occur
Look at:
SELECT*
FROM users
INNERJOIN orders
ON users.id = orders.user_id;
Mary has:
3 orders
Therefore:
Mary appears 3 times
JOINs multiply rows.
This is one of the most misunderstood SQL concepts.
DISTINCT After JOIN
Sometimes we want unique users.
SELECTDISTINCT users.*
FROM users
JOIN orders
ON users.id = orders.user_id;
Rails
User.joins(:orders).distinct
The N+1 Query Problem
Every Rails interview asks this.
Suppose:
users=User.all
users.eachdo |user|
putsuser.orders.count
end
Queries:
SELECT*FROM users;
Then:
SELECT*FROM orders WHERE user_id=1;
SELECT*FROM orders WHERE user_id=2;
SELECT*FROM orders WHERE user_id=3;
...
100 users:
101 queries
Called:
N+1 problem
Fix Using includes
User.includes(:orders)
Rails loads:
SELECT*FROM users;
and
SELECT*FROM orders
WHERE user_id IN(...);
Only 2 queries.
joins vs includes
This is a favorite interview question.
joins
Used for filtering.
User.joins(:orders)
SQL:
INNERJOIN
Purpose:
Filter data
includes
Used for eager loading.
User.includes(:orders)
Purpose:
Avoid N+1
Example
Find users with orders.
User.joins(:orders)
Display users and orders.
User.includes(:orders)
Interview Question
Which is better?
joins
or
includes
Answer:
Depends on the problem.
Different purposes.
Real Interview Queries
Find users with orders
SELECTDISTINCT users.*
FROM users
JOIN orders
ON users.id = orders.user_id;
Rails:
User.joins(:orders).distinct
Find users without orders
SELECT users.*
FROM users
LEFTJOIN orders
ON users.id = orders.user_id
WHERE orders.id ISNULL;
Rails:
User.left_joins(:orders)
.where(orders: { id:nil })
Find total orders per user
SELECT
users.name,
COUNT(orders.id)
FROM users
LEFTJOIN orders
ON users.id = orders.user_id
GROUPBY users.name;
We’ll study GROUP BY in Day 4.
Senior-Level Insights
1. Most Rails JOINs are INNER JOINs
joins
means:
INNERJOIN
2. LEFT JOIN + IS NULL
is the standard pattern for:
Find missing records
Examples:
users without orders
customers without subscriptions
products without inventory
3. JOINs Multiply Rows
One user:
3 orders
becomes:
3 result rows
Always remember this.
4. Understand the Generated SQL
When writing:
User.joins(:orders)
you should mentally see:
INNERJOIN orders
ON orders.user_id = users.id
immediately.
Practical Exercises
Exercise 1
Find all users with orders.
Exercise 2
Find all users without orders.
Exercise 3
Add 3 more orders for John.
Run:
INNERJOIN
Observe duplicates.
Exercise 4
Use:
DISTINCT
to remove duplicates.
Exercise 5
Create:
categories
products
and practice:
INNERJOIN
LEFTJOIN
Homework
Create the following schema:
authors
books
publishers
Relationships:
Author has many Books
Publisher has many Books
Book belongs to Author
Book belongs to Publisher
Insert sample data.
Write queries:
Books with author names
Books with publisher names
Authors without books
Publishers without books
Unique authors who have books
Write both:
SQL
ActiveRecord
Question:
I cannot see any difference in output between left join and full outer join with our example. can u create a new data to see me correct difference between them?
This is actually something that confuses many developers in interviews.
The reason you don’t see any difference is because our current data doesn’t have any “orphan” records in the orders table.
Let’s understand why.
Our Current Data
users
id
name
1
John
2
Mary
3
Bob
4
Alice
orders
id
user_id
amount
1
1
100
2
1
200
3
2
300
4
2
400
5
2
500
Every order belongs to an existing user.
So there are:
Users without orders (Bob, Alice)
No orders without users
That’s why LEFT JOIN and FULL OUTER JOIN appear almost identical.
Let’s Create a Better Example
To see the difference, we need an order that doesn’t match any user.
However…
Our foreign key prevents that.
FOREIGNKEY(user_id)
REFERENCES users(id)
This is a good thing because it maintains data integrity.
So for learning purposes, we’ll create another table without a foreign key.
Step 1
DROPTABLEIFEXISTS orders_demo;
CREATETABLE orders_demo (
id BIGSERIAL PRIMARYKEY,
user_id BIGINT,
amount NUMERIC(10,2)
);
Notice:
❌ No foreign key.
Step 2
Insert data
INSERTINTO orders_demo(user_id, amount)
VALUES
(1,100),
(1,200),
(2,300),
(999,400);
Now we have:
users
id
name
1
John
2
Mary
3
Bob
4
Alice
orders_demo
id
user_id
amount
1
1
100
2
1
200
3
2
300
4
999
400
Notice:
user_id = 999
There is no matching user.
This is our orphan order.
INNER JOIN
SELECT
u.id,
u.name,
o.amount
FROM users u
INNERJOIN orders_demo o
ON u.id = o.user_id;
Result
name
amount
John
100
John
200
Mary
300
The orphan order disappears.
LEFT JOIN
SELECT
u.id,
u.name,
o.amount
FROM users u
LEFTJOIN orders_demo o
ON u.id = o.user_id;
Result
name
amount
John
100
John
200
Mary
300
Bob
NULL
Alice
NULL
Question:
Where is the orphan order?
It is gone!
Why?
Because LEFT JOIN keeps every row from the left table (users). Since there is no user with id = 999, there is nothing on the left to preserve.
FULL OUTER JOIN
SELECT
u.id,
u.name,
o.user_id,
o.amount
FROM users u
FULLOUTERJOIN orders_demo o
ON u.id = o.user_id;
Result
user id
name
order user_id
amount
1
John
1
100
1
John
1
200
2
Mary
2
300
3
Bob
NULL
NULL
4
Alice
NULL
NULL
NULL
NULL
999
400
Now you finally see the difference!
The last row exists only because of FULL OUTER JOIN.
SELECT *
FROM users
WHERE city = 'Chicago'
AND active = true
ORDER BY salary DESC
LIMIT 2;
into ActiveRecord.
Solution:
User.where(city: "Chicago", active: true)
.order(salary: :desc)
.limit(2)
#If you only need the name, city, and salary:
User.where(city: "Chicago", active: true)
.order(salary: :desc)
.limit(2)
.pluck(:name, :city, :salary)
Common Mistakes
Mistake 1
WHERE age =NULL
Wrong.
Use:
WHERE age ISNULL
Mistake 2
Using:
SELECT*
everywhere.
Mistake 3
Forgetting ORDER BY when using LIMIT.
LIMIT5
without ordering can return arbitrary rows.
Mistake 4
Using huge OFFSET values.
Senior-Level Knowledge
Understand that SQL logically executes in this order:
FROM
WHERE
SELECT
DISTINCT
ORDER BY
LIMIT
Even though we write:
SELECT ...
FROM ...
WHERE ...
PostgreSQL conceptually processes the clauses in the above order.
This understanding becomes extremely important when we move to:
JOINs
GROUP BY
HAVING
Query Optimization
EXPLAIN ANALYZE
Homework
Create a new table:
CREATETABLE products (
id BIGSERIAL PRIMARYKEY,
name VARCHAR(100),
category VARCHAR(100),
price NUMERIC(10,2),
stock_quantity INTEGER
);
Insert at least 10 records.
Practice:
SELECT specific columns
WHERE with multiple conditions
ORDER BY price DESC
LIMIT 5
DISTINCT categories
BETWEEN on price
LIKE searches
Products with stock_quantity IS NULL
Day 3 Preview
Next we’ll cover one of the most important interview topics:
JOINs
Including:
INNER JOIN
LEFT JOIN
RIGHT JOIN
FULL JOIN
CROSS JOIN
Self Join
ActiveRecord joins
includes vs joins vs preload vs eager_load
Real Rails interview questions
Day 3 is where SQL starts becoming truly powerful.
I am working on a project where we face issues in an ancestral path data in PostgreSql DB. Working with hierarchical data in PostgreSQL often involves dealing with ancestry paths stored as delimited strings. This comprehensive guide explores how to extract specific values from ancestry columns and utilize them effectively in join operations, complete with practical examples, troubleshooting tips and how I fixed the issues.
PostgreSQL’s robust string manipulation capabilities make it ideal for handling complex hierarchical data structures. When working with ancestry values stored in text columns, you often need to extract specific parts of the hierarchy for data analysis, reporting, or joining operations.
This article demonstrates how to:
✨ Extract values from ancestry strings using regular expressions
🔗 Perform efficient joins on extracted ancestry data
🛡️ Handle edge cases and avoid common pitfalls
⚡ Optimize queries for better performance
❓ Problem Statement
📊 Scenario
Consider a projects table with an ancestry column containing hierarchical paths like:
Extract the last integer value from the ancestry path
Use this value in a JOIN operation to fetch parent project data
Handle edge cases like NULL values and malformed strings
🏗️ Understanding the Data Structure
📁 Table Structure
CREATE TABLE projects (
id BIGINT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
ancestry TEXT, -- Stores parent hierarchy as "id1/id2/id3"
created_at TIMESTAMP DEFAULT NOW()
);
-- Sample data
INSERT INTO projects (id, name, ancestry) VALUES
(1, 'Root Project', NULL),
(2, 'Department A', '1'),
(3, 'Team Alpha', '1/2'),
(4, 'Task 1', '1/2/3'),
(5, 'Subtask 1A', '1/2/3/4');
🧭 Ancestry Path Breakdown
Project ID
Name
Ancestry
Immediate Parent
1
Root Project
NULL
None (root)
2
Department A
1
1
3
Team Alpha
1/2
2
4
Task 1
1/2/3
3
5
Subtask 1A
1/2/3/4
4
🔧 Solution Overview
🎯 Core Approach
🔍 Pattern Matching: Use regex to identify the last number in the ancestry string
✂️ Value Extraction: Extract the matched value using regexp_replace()
🔄 Type Conversion: Cast the extracted string to the appropriate numeric type
🔗 Join Operation: Use the converted value in JOIN conditions
📝 Basic Query Structure
SELECT projects.*
FROM projects
LEFT OUTER JOIN projects AS parent_project
ON CAST(
regexp_replace(projects.ancestry, '.*\/(\d+)$', '\1')
AS BIGINT
) = parent_project.id
WHERE projects.ancestry IS NOT NULL;
📝 Regular Expression Deep Dive
🎯 Pattern Breakdown: .*\/(\d+)$
Let’s dissect this regex pattern:
.* -- Match any characters (greedy)
\/ -- Match literal forward slash
(\d+) -- Capture group: one or more digits
$ -- End of string anchor
📊 Pattern Matching Examples
Ancestry String
Regex Match
Captured Group
Result
"6/4/5/3"
5/3
3
✅ 3
"1/2"
1/2
2
✅ 2
"9"
No match
–
❌ Original string
"abc/def"
No match
–
❌ Original string
🔧 Alternative Regex Patterns
-- For single-level ancestry (no slashes)
regexp_replace(ancestry, '^(\d+)$', '\1')
-- For extracting first parent instead of last
regexp_replace(ancestry, '^(\d+)\/.*', '\1')
-- For handling mixed delimiters (/ or -)
regexp_replace(ancestry, '.*[\/\-](\d+)$', '\1')
💻 Implementation Examples
🔧 Example 1: Basic Parent Lookup
-- Find each project with its immediate parent information
SELECT
p.id,
p.name AS project_name,
p.ancestry,
parent.id AS parent_id,
parent.name AS parent_name
FROM projects p
LEFT OUTER JOIN projects parent
ON CAST(
regexp_replace(p.ancestry, '.*\/(\d+)$', '\1')
AS BIGINT
) = parent.id
WHERE p.ancestry IS NOT NULL
ORDER BY p.id;
Expected Output:
id | project_name | ancestry | parent_id | parent_name
----+--------------+----------+-----------+-------------
2 | Department A | 1 | 1 | Root Project
3 | Team Alpha | 1/2 | 2 | Department A
4 | Task 1 | 1/2/3 | 3 | Team Alpha
5 | Subtask 1A | 1/2/3/4 | 4 | Task 1
🎯 Example 2: Handling Edge Cases
-- Robust query that handles all edge cases
SELECT
p.id,
p.name AS project_name,
p.ancestry,
CASE
WHEN p.ancestry IS NULL THEN 'Root Level'
WHEN p.ancestry !~ '.*\/(\d+)$' THEN 'Single Parent'
ELSE 'Multi-level'
END AS hierarchy_type,
parent.name AS parent_name
FROM projects p
LEFT OUTER JOIN projects parent ON
CASE
-- Handle multi-level ancestry
WHEN p.ancestry ~ '.*\/(\d+)$' THEN
CAST(regexp_replace(p.ancestry, '.*\/(\d+)$', '\1') AS BIGINT)
-- Handle single-level ancestry
WHEN p.ancestry ~ '^\d+$' THEN
CAST(p.ancestry AS BIGINT)
ELSE NULL
END = parent.id
ORDER BY p.id;
📈 Example 3: Aggregating Child Counts
-- Count children for each project
WITH parent_child_mapping AS (
SELECT
p.id AS child_id,
CASE
WHEN p.ancestry ~ '.*\/(\d+)$' THEN
CAST(regexp_replace(p.ancestry, '.*\/(\d+)$', '\1') AS BIGINT)
WHEN p.ancestry ~ '^\d+$' THEN
CAST(p.ancestry AS BIGINT)
ELSE NULL
END AS parent_id
FROM projects p
WHERE p.ancestry IS NOT NULL
)
SELECT
p.id,
p.name,
COUNT(pcm.child_id) AS direct_children_count
FROM projects p
LEFT JOIN parent_child_mapping pcm ON p.id = pcm.parent_id
GROUP BY p.id, p.name
ORDER BY direct_children_count DESC;
-- ✅ Correct: Cast only the extracted value
CAST(
regexp_replace(projects.ancestry, '.*\/(\d+)$', '\1')
AS BIGINT
) = parent.id
❌ Error 2: Unexpected Results with Single-Level Ancestry
Problem: Single values like "9" don’t match the pattern .*\/(\d+)$
Solution:
-- ✅ Handle both multi-level and single-level ancestry
CASE
WHEN ancestry ~ '.*\/(\d+)$' THEN
CAST(regexp_replace(ancestry, '.*\/(\d+)$', '\1') AS BIGINT)
WHEN ancestry ~ '^\d+$' THEN
CAST(ancestry AS BIGINT)
ELSE NULL
END
❌ Error 3: NULL Ancestry Values Causing Issues
Problem: NULL values can cause unexpected behaviour in joins
Solution:
-- ✅ Explicitly handle NULL values
WHERE ancestry IS NOT NULL
AND ancestry != ''
🛡️ Complete Error-Resistant Query
SELECT
p.id,
p.name AS project_name,
p.ancestry,
parent.id AS parent_id,
parent.name AS parent_name
FROM projects p
LEFT OUTER JOIN projects parent ON
CASE
WHEN p.ancestry IS NULL OR p.ancestry = '' THEN NULL
WHEN p.ancestry ~ '.*\/(\d+)$' THEN
CAST(regexp_replace(p.ancestry, '.*\/(\d+)$', '\1') AS BIGINT)
WHEN p.ancestry ~ '^\d+$' THEN
CAST(p.ancestry AS BIGINT)
ELSE NULL
END = parent.id
ORDER BY p.id;
⚡ Performance Considerations
📊 Indexing Strategies
-- Create index on ancestry for faster pattern matching
CREATE INDEX idx_projects_ancestry ON projects (ancestry);
-- Create partial index for non-null ancestry values
CREATE INDEX idx_projects_ancestry_not_null
ON projects (ancestry)
WHERE ancestry IS NOT NULL;
-- Create functional index for extracted parent IDs
CREATE INDEX idx_projects_parent_id ON projects (
CASE
WHEN ancestry ~ '.*\/(\d+)$' THEN
CAST(regexp_replace(ancestry, '.*\/(\d+)$', '\1') AS BIGINT)
WHEN ancestry ~ '^\d+$' THEN
CAST(ancestry AS BIGINT)
ELSE NULL
END
) WHERE ancestry IS NOT NULL;
🔄 Query Optimization Tips
🎯 Use CTEs for Complex Logic
WITH parent_lookup AS (
SELECT
id,
CASE
WHEN ancestry ~ '.*\/(\d+)$' THEN
CAST(regexp_replace(ancestry, '.*\/(\d+)$', '\1') AS BIGINT)
WHEN ancestry ~ '^\d+$' THEN
CAST(ancestry AS BIGINT)
END AS parent_id
FROM projects
WHERE ancestry IS NOT NULL
)
SELECT p.*, parent.name AS parent_name
FROM parent_lookup p
JOIN projects parent ON p.parent_id = parent.id;
⚡ Consider Materialized Views for Frequent Queries
CREATE MATERIALIZED VIEW project_hierarchy AS
SELECT
p.id,
p.name,
p.ancestry,
CASE
WHEN p.ancestry ~ '.*\/(\d+)$' THEN
CAST(regexp_replace(p.ancestry, '.*\/(\d+)$', '\1') AS BIGINT)
WHEN p.ancestry ~ '^\d+$' THEN
CAST(p.ancestry AS BIGINT)
END AS parent_id
FROM projects p;
-- Refresh when data changes
REFRESH MATERIALIZED VIEW project_hierarchy;
🛠️ Advanced Techniques
🔍 Extracting Multiple Ancestry Levels
-- Extract all ancestry levels as an array
SELECT
id,
name,
ancestry,
string_to_array(ancestry, '/') AS ancestry_array,
-- Get specific levels
split_part(ancestry, '/', 1) AS level_1,
split_part(ancestry, '/', 2) AS level_2,
split_part(ancestry, '/', -1) AS last_level
FROM projects
WHERE ancestry IS NOT NULL;
🧮 Calculating Hierarchy Depth
-- Calculate the depth of each project in the hierarchy
SELECT
id,
name,
ancestry,
CASE
WHEN ancestry IS NULL THEN 0
ELSE array_length(string_to_array(ancestry, '/'), 1)
END AS hierarchy_depth
FROM projects
ORDER BY hierarchy_depth, id;
🌳 Building Complete Hierarchy Paths
-- Recursive CTE to build full hierarchy paths
WITH RECURSIVE hierarchy_path AS (
-- Base case: root projects
SELECT
id,
name,
ancestry,
name AS full_path,
0 AS level
FROM projects
WHERE ancestry IS NULL
UNION ALL
-- Recursive case: child projects
SELECT
p.id,
p.name,
p.ancestry,
hp.full_path || ' → ' || p.name AS full_path,
hp.level + 1 AS level
FROM projects p
JOIN hierarchy_path hp ON
CASE
WHEN p.ancestry ~ '.*\/(\d+)$' THEN
CAST(regexp_replace(p.ancestry, '.*\/(\d+)$', '\1') AS BIGINT)
WHEN p.ancestry ~ '^\d+$' THEN
CAST(p.ancestry AS BIGINT)
END = hp.id
)
SELECT * FROM hierarchy_path
ORDER BY level, id;
✅ Best Practices
🎯 Data Validation
✅ Validate Ancestry Format on Insert/Update
-- Add constraint to ensure valid ancestry format
ALTER TABLE projects
ADD CONSTRAINT check_ancestry_format
CHECK (
ancestry IS NULL
OR ancestry ~ '^(\d+)(\/\d+)*$'
);
🔍 Regular Data Integrity Checks
-- Find orphaned projects (ancestry points to non-existent parent)
SELECT p.id, p.name, p.ancestry
FROM projects p
WHERE p.ancestry IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM projects parent
WHERE parent.id = CASE
WHEN p.ancestry ~ '.*\/(\d+)$' THEN
CAST(regexp_replace(p.ancestry, '.*\/(\d+)$', '\1') AS BIGINT)
WHEN p.ancestry ~ '^\d+$' THEN
CAST(p.ancestry AS BIGINT)
END
);
🛡️ Error Handling
-- Function to safely extract parent ID
CREATE OR REPLACE FUNCTION extract_parent_id(ancestry_text TEXT)
RETURNS BIGINT AS $$
BEGIN
IF ancestry_text IS NULL OR ancestry_text = '' THEN
RETURN NULL;
END IF;
IF ancestry_text ~ '.*\/(\d+)$' THEN
RETURN CAST(regexp_replace(ancestry_text, '.*\/(\d+)$', '\1') AS BIGINT);
ELSIF ancestry_text ~ '^\d+$' THEN
RETURN CAST(ancestry_text AS BIGINT);
ELSE
RETURN NULL;
END IF;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Usage
SELECT p.*, parent.name AS parent_name
FROM projects p
LEFT JOIN projects parent ON extract_parent_id(p.ancestry) = parent.id;
📊 Monitoring and Maintenance
-- Query to analyze ancestry data quality
SELECT
'Total Projects' AS metric,
COUNT(*) AS count
FROM projects
UNION ALL
SELECT
'Projects with Ancestry' AS metric,
COUNT(*) AS count
FROM projects
WHERE ancestry IS NOT NULL
UNION ALL
SELECT
'Valid Ancestry Format' AS metric,
COUNT(*) AS count
FROM projects
WHERE ancestry ~ '^(\d+)(\/\d+)*$'
UNION ALL
SELECT
'Orphaned Projects' AS metric,
COUNT(*) AS count
FROM projects p
WHERE p.ancestry IS NOT NULL
AND extract_parent_id(p.ancestry) NOT IN (SELECT id FROM projects);
📝 Conclusion
Working with ancestry data in PostgreSQL requires careful handling of string manipulation, type conversion, and edge cases. By following the techniques outlined in this guide, you can:
🎯 Key Takeaways
🔍 Use robust regex patterns to handle different ancestry formats
🛡️ Always handle edge cases like NULL values and malformed strings
⚡ Consider performance implications and use appropriate indexing
✅ Implement data validation to maintain ancestry integrity
🔧 Create reusable functions for complex extraction logic
💡 Final Recommendations
🎯 Test thoroughly with various ancestry formats
📊 Monitor query performance and optimize as needed
🔄 Consider alternative approaches like ltree for complex hierarchies
📚 Document your ancestry format for team members
🛠️ Implement proper error handling in production code
The techniques demonstrated here provide a solid foundation for working with hierarchical data in PostgreSQL. Whether you’re building organizational charts, category trees, or project hierarchies, these patterns will help you extract and manipulate ancestry data effectively and reliably! 🚀
List of commands to remember using postgres DB managment system.
Login, Create user and password
# login to psql client
psql postgres # OR
psql -U postgres
create database mydb; # create db
create user abhilash with SUPERUSER CREATEDB CREATEROLE encrypted password 'abhilashPass!';
grant all privileges on database mydb to myuser; # add privileges
Connect to DB, List tables and users, functions, views, schema
\l # lists all the databases
\c dbname # connect to db
\dt # show tables
\d table_name # Describe a table
\dn # List available schema
\df # List available functions
\dS [your_table_name] # List triggers
\dv # List available views
\du # lists all user accounts and roles
\du+ # is the extended version which shows even more information.
Show history, save to file, edit using editor, execution time, help
SELECT version(); # version of psql
\g # Execute the previous command
\s # Command history
\s filename # save Command history to a file
\i filename # Execute psql commands from a file
\? # help on psql commands
\h ALTER TABLE # To get help on specific PostgreSQL statement
\timing # Turn on/off query execution time
\e # Edit command in your own editor
\e [function_name] # It is more useful when you edit a function in the editor. Do \df for functions
\o [file_name] # send all next query results to file
\o out.txt
\dt
\o # switch
\dt
Change output, Quit psql
# Switch output options
\a command switches from aligned to non-aligned column output.
\H command formats the output to HTML format.
\q # quit psql
and then copy the *-service.jar into the same folder
You can see these are processing and started in the server logs.
INFO [com.liferay.portal.kernel.deploy.auto.AutoDeployScanner][AutoDeployDir:263] Processing sitesService.api.jar
~/liferay-ce-portal-tomcat-7.3.0-ga1-20200127150653953/liferay-ce-portal-7.3.0-ga1/osgi/modules][BundleStartStopLogger:39] STARTED sitesService.api_1.0.0 [1115]
[com.liferay.portal.kernel.deploy.auto.AutoDeployScanner][AutoDeployDir:263] Processing sitesService.service.jar
~/liferay-ce-portal-tomcat-7.3.0-ga1-20200127150653953/liferay-ce-portal-7.3.0-ga1/osgi/modules][BundleStartStopLogger:39] STARTED sitesService.service_1.0.0 [1116]
Now check the database, if the Site_ table with all columns are created or not
You can see the table and columns are created. In the next topic we discuss about adding services to this service builder.