Learn SQL: Day 4 – GROUP BY & Aggregate Functions

Welcome to Day 4.

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)
);

Insert Users

INSERT INTO users(name, city)
VALUES
('John', 'New York'),
('Mary', 'Chicago'),
('Bob', 'Chicago'),
('Alice', 'Boston');

Insert Orders

INSERT INTO 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');

Let’s first see the data.

SELECT * FROM users;
idnamecity
1JohnNew York
2MaryChicago
3BobChicago
4AliceBoston
SELECT * FROM orders;
user_idamountstatus
1100completed
1250completed
175pending
2500completed
2300completed
3200pending
4800completed
4150cancelled

Part 1 – Aggregate Functions

Aggregate functions operate on multiple rows and return a single value.

Examples:

FunctionPurpose
COUNTCount rows
SUMAdd values
AVGAverage
MINSmallest value
MAXLargest value

Think of them as summary functions.

COUNT()

How many orders exist?

SELECT COUNT(*)
FROM orders;

Result

8

COUNT(column)

SELECT COUNT(status)
FROM orders;

Counts only non-NULL values.

If one status were NULL:

COUNT(*) = 8
COUNT(status) = 7

This difference is asked surprisingly often in interviews.

Rails Equivalent

Order.count

COUNT(DISTINCT)

How many cities do users belong to?

SELECT COUNT(DISTINCT city)
FROM users;

Result

3

Cities:

  • Chicago
  • Boston
  • New York

Rails:

User.distinct.count(:city)

SUM()

Total revenue:

SELECT SUM(amount)
FROM orders;

Calculation:

100
+250
+75
+500
+300
+200
+800
+150
-------
2375

Rails:

Order.sum(:amount)

AVG()

Average order amount.

SELECT AVG(amount)
FROM orders;

Rails

Order.average(:amount)

MIN()

SELECT MIN(amount)
FROM orders;

Result

75

MAX()

SELECT MAX(amount)
FROM orders;

Result

800

Rails

Order.maximum(:amount)

Part 2 – GROUP BY

This is today’s biggest topic.

Without GROUP BY:

SELECT SUM(amount)
FROM orders;

One answer.

2375

Suppose the business asks:

What’s the total revenue for each customer?

Now we need GROUP BY.

GROUP BY user_id

SELECT
user_id,
SUM(amount)
FROM orders
GROUP BY user_id;

Result

user_idsum
1425
2800
3200
4950

Notice:

PostgreSQL divides the rows into groups first.

User 1
100
250
75
425

Then repeats for every user.

Rails Equivalent

Order.group(:user_id).sum(:amount)

Returns:

{
1 => 425,
2 => 800,
3 => 200,
4 => 950
}

Visualizing GROUP BY

Imagine sorting all orders into buckets.

Orders
User 1 Bucket
100
250
75
425
User 2 Bucket
500
300
800

This is essentially what GROUP BY does conceptually.

GROUP BY City

SELECT
city,
COUNT(*)
FROM users
GROUP BY city;

Result

citycount
Chicago2
Boston1
New York1

Rails

User.group(:city).count

Common Interview Question

Why doesn’t this work?

SELECT
user_id,
amount
FROM orders
GROUP BY user_id;

PostgreSQL responds:

column “amount” must appear in the GROUP BY clause or be used in an aggregate function

Why?

Because each group contains multiple amount values. PostgreSQL doesn’t know which one you want.

For user_id = 1, there are three amounts:

  • 100
  • 250
  • 75

Which one should it return?

It can’t guess.

Rule to Remember

Every selected column must be:

  • in GROUP BY, or
  • inside an aggregate function.

Correct:

SELECT
user_id,
SUM(amount)
FROM orders
GROUP BY user_id;

GROUP BY Multiple Columns

SELECT
user_id,
status,
COUNT(*)
FROM orders
GROUP BY user_id, status;

Result

user_idstatuscount
1completed2
1pending1
2completed2
3pending1
4completed1
4cancelled1

Now each unique (user_id, status) combination becomes its own group.

GROUP BY with JOIN

Very common interview question.

Show each user’s total spending.

SELECT
u.name,
SUM(o.amount) AS total_spent
FROM users u
JOIN orders o
ON u.id = o.user_id
GROUP BY u.name;

Result

nametotal_spent
John425
Mary800
Bob200
Alice950

Rails

User
.joins(:orders)
.group(:name)
.sum("orders.amount")

What if a User Has No Orders?

Let’s add one.

INSERT INTO users(name, city)
VALUES ('David', 'Mumbai');

Now run:

SELECT
u.name,
SUM(o.amount)
FROM users u
LEFT JOIN orders o
ON u.id = o.user_id
GROUP BY u.name;

Result

namesum
John425
Mary800
Bob200
Alice950
DavidNULL

Notice:

LEFT JOIN keeps David, even though he has no orders.


COALESCE()

Business users usually don’t want NULL.

They want:

0

Use:

SELECT
u.name,
COALESCE(SUM(o.amount), 0) AS total_spent
FROM users u
LEFT JOIN orders o
ON u.id = o.user_id
GROUP BY u.name;

Result

nametotal_spent
David0

We’ll study COALESCE in more detail later, but it’s useful to know this pattern now.


HAVING

Many developers confuse WHERE and HAVING.

This is a favorite interview topic.

Suppose we want:

Show only customers who spent more than 500.

Wrong:

SELECT
user_id,
SUM(amount)
FROM orders
WHERE SUM(amount) > 500
GROUP BY user_id;

This fails because WHERE filters individual rows before grouping. At that stage, SUM(amount) hasn’t been calculated yet.

Correct:

SELECT
user_id,
SUM(amount)
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 500;

Result

user_idsum
2800
4950

WHERE vs HAVING

Think of the execution order:

FROM
WHERE
GROUP BY
Aggregate Functions
HAVING
SELECT
ORDER BY
  • WHERE filters rows before grouping.
  • HAVING filters groups after aggregation.

Rails Equivalent

Order
.group(:user_id)
.having("SUM(amount) > ?", 500)
.sum(:amount)

Real Interview Questions

Top Spending Customer

SELECT
user_id,
SUM(amount) AS total
FROM orders
GROUP BY user_id
ORDER BY total DESC
LIMIT 1;

Number of Orders Per Status

SELECT
status,
COUNT(*)
FROM orders
GROUP BY status;

Average Order Value Per User

SELECT
user_id,
AVG(amount)
FROM orders
GROUP BY user_id;

Highest Single Order Per User

SELECT
user_id,
MAX(amount)
FROM orders
GROUP BY user_id;

Common Mistakes

Mistake 1

Selecting non-grouped columns.

SELECT user_id, amount
FROM orders
GROUP BY user_id;

Mistake 2

Using WHERE instead of HAVING for aggregate conditions.

Mistake 3

Using an INNER JOIN when the business wants users with zero orders.

Use a LEFT JOIN plus COALESCE.


Senior-Level Insights

  1. GROUP BY changes the shape of your data. You no longer have one row per order—you have one row per group.
  2. Aggregate functions ignore NULL values (except COUNT(*), which counts rows regardless).
  3. Always think about the business question first. Ask yourself:
    • “What am I grouping by?”
    • “What summary do I want for each group?”
  4. JOIN + GROUP BY is the foundation of almost every reporting query you’ll write.

Practical Exercises

  1. Count the total number of users.
  2. Calculate the total revenue from completed orders only.
  3. Find the average order amount for each user.
  4. Count how many orders each status has.
  5. Find users who have placed more than two orders.
  6. Show each city and the total number of users in that city.
  7. Add another user with no orders and show them with a total spend of 0.
  8. Find the highest order amount for every user.

Homework

Create two new tables:

departments
idname
employees
iddepartment_idsalary

Insert data for at least:

  • 3 departments
  • 10 employees

Then write SQL to answer:

  1. Number of employees per department.
  2. Average salary per department.
  3. Highest salary per department.
  4. Departments with more than 3 employees.
  5. 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
GROUP BY status
HAVING COUNT(*) >= 2
ORDER BY 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.

Happy Learning! 


Learn SQL: Day 3 – JOINs (One of the Most Important SQL Topic)

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

DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100)
);

Orders

CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
amount NUMERIC(10,2),
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
);

Insert Sample Data

Users:

INSERT INTO users(name)
VALUES
('John'),
('Mary'),
('Bob'),
('Alice');

Orders:

INSERT INTO orders(user_id, amount)
VALUES
(1,100),
(1,200),
(2,300),
(2,400),
(2,500);

Current data:

users

idname
1John
2Mary
3Bob
4Alice

orders

iduser_idamount
11100
21200
32300
42400
52500

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
INNER JOIN orders
ON users.id = orders.user_id;

Result:

nameamount
John100
John200
Mary300
Mary400
Mary500

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
INNER JOIN 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
LEFT JOIN orders
ON users.id = orders.user_id;

Result:

nameamount
John100
John200
Mary300
Mary400
Mary500
BobNULL
AliceNULL

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:

LEFT OUTER JOIN

Practical Example

Find users without orders.

SELECT users.*
FROM users
LEFT JOIN orders
ON users.id = orders.user_id
WHERE orders.id IS NULL;

Result:

Bob
Alice

Rails:

User.left_joins(:orders)
.where(orders: { id: nil })

Common Interview Question

Find customers who never placed an order.

Expected answer:

LEFT JOIN
+
IS NULL

3. RIGHT JOIN

Opposite of LEFT JOIN.

Keep all rows from right table.

SELECT *
FROM users
RIGHT JOIN 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
FULL OUTER JOIN 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:

CREATE TABLE colors (
color VARCHAR(20)
);
INSERT INTO colors
VALUES ('Red'),('Blue');

Sizes:

CREATE TABLE sizes (
size VARCHAR(20)
);
INSERT INTO sizes
VALUES ('S'),('M');

Query:

SELECT *
FROM colors
CROSS JOIN 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:

CREATE TABLE employees (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
manager_id BIGINT
);

Insert:

INSERT INTO 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
LEFT JOIN employees m
ON e.manager_id = m.id;

Result:

employeemanager
CEONULL
Manager1CEO
Developer1Manager1

Rails

class Employee < 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
INNER JOIN 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.

SELECT DISTINCT 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.each do |user|
puts user.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:

INNER JOIN

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

SELECT DISTINCT users.*
FROM users
JOIN orders
ON users.id = orders.user_id;

Rails:

User.joins(:orders).distinct

Find users without orders

SELECT users.*
FROM users
LEFT JOIN orders
ON users.id = orders.user_id
WHERE orders.id IS NULL;

Rails:

User.left_joins(:orders)
.where(orders: { id: nil })

Find total orders per user

SELECT
users.name,
COUNT(orders.id)
FROM users
LEFT JOIN orders
ON users.id = orders.user_id
GROUP BY users.name;

We’ll study GROUP BY in Day 4.

Senior-Level Insights

1. Most Rails JOINs are INNER JOINs

joins

means:

INNER JOIN

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:

INNER JOIN 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:

INNER JOIN

Observe duplicates.

Exercise 4

Use:

DISTINCT

to remove duplicates.

Exercise 5

Create:

categories
products

and practice:

INNER JOIN
LEFT JOIN

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:

  1. Books with author names
  2. Books with publisher names
  3. Authors without books
  4. Publishers without books
  5. 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

idname
1John
2Mary
3Bob
4Alice

orders

iduser_idamount
11100
21200
32300
42400
52500

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.

FOREIGN KEY (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

DROP TABLE IF EXISTS orders_demo;
CREATE TABLE orders_demo (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
amount NUMERIC(10,2)
);

Notice:

❌ No foreign key.

Step 2

Insert data

INSERT INTO orders_demo(user_id, amount)
VALUES
(1,100),
(1,200),
(2,300),
(999,400);

Now we have:

users

idname
1John
2Mary
3Bob
4Alice

orders_demo

iduser_idamount
11100
21200
32300
4999400

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
INNER JOIN orders_demo o
ON u.id = o.user_id;

Result

nameamount
John100
John200
Mary300

The orphan order disappears.

LEFT JOIN

SELECT
u.id,
u.name,
o.amount
FROM users u
LEFT JOIN orders_demo o
ON u.id = o.user_id;

Result

nameamount
John100
John200
Mary300
BobNULL
AliceNULL

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
FULL OUTER JOIN orders_demo o
ON u.id = o.user_id;

Result

user idnameorder user_idamount
1John1100
1John1200
2Mary2300
3BobNULLNULL
4AliceNULLNULL
NULLNULL999400

Now you finally see the difference!

The last row exists only because of FULL OUTER JOIN.

When to use FULL OUTER JOIN?

Check the page: https://railsdrop.com/learn-sql-day-3-when-to-use-full-outer-join/


Quick Quiz

Given these tables:

A

id
1
2
3

B

id
2
3
4

Without running SQL, what rows do you expect from:

  1. INNER JOIN
  2. LEFT JOIN (A LEFT JOIN B)
  3. RIGHT JOIN (A RIGHT JOIN B)
  4. FULL OUTER JOIN

Try answering these on paper first. If you can do that confidently, you’ve truly understood how the different joins work.


Day 4 Preview

Tomorrow we’ll cover one of the highest-frequency SQL interview topics:

GROUP BY & Aggregations

Including:

  • COUNT
  • SUM
  • AVG
  • MIN
  • MAX
  • GROUP BY
  • HAVING
  • Aggregate queries in Rails
  • Real interview problems such as:
    • Top customers
    • Revenue calculations
    • Most purchased products
    • Reporting queries

This is where SQL starts becoming analytical rather than just relational.

Happy Learning! 

Learn SQL: Day 2 – SELECT Queries (The Foundation of SQL)

Today we start writing queries.

Today’s Goals

By the end of Day 2 you should understand:

  • SELECT
  • WHERE
  • ORDER BY
  • LIMIT
  • OFFSET
  • DISTINCT
  • IN
  • BETWEEN
  • LIKE
  • ILIKE
  • NULL handling
  • ActiveRecord equivalents
  • Common interview questions
  • Common mistakes

Step 1: Create Our Practice Database

Connect:

psql sql_day1

Let’s create a new table.

DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(255),
age INTEGER,
city VARCHAR(100),
salary NUMERIC(10,2),
active BOOLEAN,
created_at TIMESTAMP DEFAULT NOW()
);

Insert Sample Data

INSERT INTO users
(name, email, age, city, salary, active)
VALUES
('John', 'john@test.com', 30, 'New York', 70000, true),
('Mary', 'mary@test.com', 25, 'Chicago', 60000, true),
('Bob', 'bob@test.com', 35, 'Chicago', 90000, false),
('Alice', 'alice@test.com', 28, 'Boston', 75000, true),
('Tom', 'tom@test.com', 40, 'New York', 120000, false),
('Sara', 'sara@test.com', 32, 'Boston', 85000, true),
('Mike', 'mike@test.com', NULL, 'Chicago', NULL, true);

View data:

SELECT * FROM users;

1. SELECT

The most basic query.

SELECT * FROM users;

Meaning:

Give me all columns from users

Output:

id | name | email | age | city | salary

Select Specific Columns

Instead of everything:

SELECT name, email FROM users;

Output:

name | email
--------+---------------
John | john@test.com
Mary | mary@test.com

Rails Equivalent

User.select(:name, :email)

Senior Insight

Avoid:

SELECT *

in production systems unless needed.

Why?

Because fetching unnecessary columns:

  • uses more memory
  • transfers more data
  • slows queries

Good:

SELECT id, name FROM users;

2. WHERE Clause

Filters rows.

Example

Only active users.

SELECT * FROM users
WHERE active = true;

Rails:

User.where(active: true)

Age Greater Than 30

SELECT * FROM users
WHERE age > 30;

Rails:

User.where("age > ?", 30)

Multiple Conditions

SELECT * FROM users
WHERE city = 'Chicago'
AND active = true;

Rails:

User.where(city: "Chicago", active: true)

OR

SELECT * FROM users
WHERE city = 'Chicago'
OR city = 'Boston';

Rails:

User.where(city: ["Chicago", "Boston"])

Interview Question

Which runs first?

WHERE A OR B AND C

Answer:

AND

before

OR

Use parentheses.

WHERE (A OR B)
AND C

3. ORDER BY

Sort results.

Ascending

SELECT * FROM users
ORDER BY age ASC;

Smallest age first.

Descending

SELECT * FROM users
ORDER BY salary DESC;

Highest salary first.

Rails:

User.order(salary: :desc)

Multiple Columns

SELECT * FROM users
ORDER BY city ASC, salary DESC;

Meaning:

Sort by city first
Inside each city
sort by salary

Common Interview Question

What happens if you omit ASC/DESC?

ORDER BY age

Default:

ASC

4. LIMIT

Return only N rows.

SELECT * FROM users
LIMIT 3;

Rails:

User.limit(3)

Why LIMIT Matters

Imagine:

10 million rows

Fetching all:

slow
memory-heavy
unnecessary

LIMIT reduces work.

5. OFFSET

Skip rows.

SELECT * FROM users
LIMIT 3
OFFSET 3;

Meaning:

Skip first 3
Return next 3

Rails

User.limit(3).offset(3)

Pagination Example

Page 1

LIMIT 10 OFFSET 0

Page 2

LIMIT 10 OFFSET 10

Page 3

LIMIT 10 OFFSET 20

Senior Insight

Large OFFSET values become expensive.

Example:

OFFSET 500000

PostgreSQL still scans through those rows.

Later we’ll learn:

Keyset Pagination

which is much faster.

6. DISTINCT

Remove duplicates.

Example:

SELECT city FROM users;

Result:

Chicago
Chicago
Chicago
Boston
Boston
New York

Distinct:

SELECT DISTINCT city FROM users;

Result:

Chicago
Boston
New York

Rails

User.select(:city).distinct

Multiple Columns

SELECT DISTINCT city, active FROM users;

Distinct applies to the combination.

7. IN

Cleaner alternative to multiple OR conditions.

Instead of:

WHERE city='Boston'
OR city='Chicago'
OR city='New York'

Use:

WHERE city IN
('Boston','Chicago','New York');

Rails:

User.where(city: ["Boston", "Chicago", "New York"])

8. BETWEEN

Range filtering.

Age between 25 and 35.

SELECT * FROM users
WHERE age BETWEEN 25 AND 35;

Equivalent:

age >= 25
AND
age <= 35

Rails

User.where(age: 25..35)

Salary Range

SELECT * FROM users
WHERE salary BETWEEN 60000 AND 90000;

Interview Question

Is BETWEEN inclusive?

Answer:

YES

Both boundaries included.

9. LIKE

Pattern matching.

Find names beginning with M.

SELECT * FROM users
WHERE name LIKE 'M%';

Result:

Mary
Mike

Ends With

WHERE email LIKE '%test.com'

Contains

WHERE name LIKE '%ar%'

Matches:

Mary
Sara

Wildcards

SymbolMeaning
%Any number of chars
_Exactly one char

Example

WHERE name LIKE '_o%'

Matches:

Bob
Tom

10. ILIKE

PostgreSQL-specific.

Case-insensitive LIKE.

SELECT * FROM users
WHERE name ILIKE 'john';

Matches:

John
JOHN
john
JoHn

Rails

User.where("name ILIKE ?", "john")

Senior Interview Insight

In PostgreSQL:

LIKE

is case-sensitive.

ILIKE

is case-insensitive.

Many developers don’t know this.

11. NULL Handling

This is a favorite interview topic.

Let’s inspect:

SELECT * FROM users;

Mike has:

age = NULL
salary = NULL

Wrong

WHERE age = NULL

Returns:

Nothing

Correct

WHERE age IS NULL

Find users with no salary:

SELECT * FROM users
WHERE salary IS NULL;

Rails

User.where(age: nil)

Generates:

IS NULL

NOT NULL

SELECT * FROM users
WHERE salary IS NOT NULL;

Why NULL Is Special

SQL uses:

TRUE
FALSE
UNKNOWN

not just:

TRUE
FALSE

This is called:

Three-Valued Logic

Interviewers love asking this.

Practical Exercises

Exercise 1

Find all active users.

Exercise 2

Find users older than 30.

Exercise 3

Find users from Boston.

Exercise 4

Find top 3 highest-paid users.

Exercise 5

Find unique cities.

Exercise 6

Find users aged between 25 and 35.

Exercise 7

Find names starting with S.

Exercise 8

Find users whose salary is NULL.

Combining Everything

Example:

SELECT name, city, salary
FROM users
WHERE active = true
AND city IN ('Chicago', 'Boston')
AND salary IS NOT NULL
ORDER BY salary DESC
LIMIT 3;

Can you explain what this query does before running it?

That’s exactly the kind of reasoning expected in senior interviews.

ActiveRecord Translation Challenge

Convert this SQL:

SELECT *
FROM users
WHERE city = 'Chicago'
AND active = true
ORDER BY salary DESC
LIMIT 2;

into ActiveRecord.

Common Mistakes

Mistake 1

WHERE age = NULL

Wrong.

Use:

WHERE age IS NULL

Mistake 2

Using:

SELECT *

everywhere.

Mistake 3

Forgetting ORDER BY when using LIMIT.

LIMIT 5

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:

CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
category VARCHAR(100),
price NUMERIC(10,2),
stock_quantity INTEGER
);

Insert at least 10 records.

Practice:

  1. SELECT specific columns
  2. WHERE with multiple conditions
  3. ORDER BY price DESC
  4. LIMIT 5
  5. DISTINCT categories
  6. BETWEEN on price
  7. LIKE searches
  8. 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.

Happy Learning! 🚀

Learn SQL: Day 1 – Relational Database Fundamentals

We’re going to learn these topics at three levels simultaneously:

  1. Database Level (PostgreSQL)
  2. SQL Level
  3. Rails / ActiveRecord Level

For every topic, ask yourself:

“How does PostgreSQL store this?”

“How do I query this with SQL?”

“How does Rails represent this?”

This will help you to prepare for interviews. We use PostgreSQL here.

Today’s Goal

By the end of Day 1, you should fully understand:

  • Database
  • Table
  • Row
  • Column
  • Primary Key
  • Foreign Key
  • Constraints
  • One-to-One
  • One-to-Many
  • Many-to-Many
  • Rails associations behind them

Part 1: What is a Database?

Imagine you are building an e-commerce application.

You need to store:

  • Users
  • Products
  • Orders
  • Payments

A database is simply a structured place to store and retrieve that information.

In PostgreSQL:

CREATE DATABASE shop_app;

Connect to it:

psql postgres

Inside psql:

CREATE DATABASE shop_app;

Connect:

\c shop_app

Verify:

SELECT current_database();

Output:

 shop_app



Part 2: What is a Table?

A table is similar to an Excel sheet.

Example:

Users table

idnameemail
1Johnjohn@test.com
2Marymary@test.com

Create it:

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(255)
);

Verify:

\d users

Interview Question:

Why not store everything in a single giant table?

Answer:

Because:

  • duplication increases
  • maintenance becomes difficult
  • relationships become unclear
  • updates become expensive

This concept is called normalization (we’ll study later).


Part 3: Rows

A row represents one record.

Insert data:

INSERT INTO users (name, email)
VALUES
('John', 'john@test.com'),
('Mary', 'mary@test.com');

View:

SELECT * FROM users;

Output:

 id | name | email
----+------+----------------
 1  | John | john@test.com
 2  | Mary | mary@test.com


Each row = one user.


Part 4: Columns

Columns describe attributes.

In users table:

id
name
email

View columns:

\d users

Senior Insight:

A database table models an entity.

Examples:

EntityTable
Userusers
Productproducts
Orderorders

Columns represent attributes of that entity.


Part 5: Primary Keys

Every row needs a unique identifier.

Example:

id BIGSERIAL PRIMARY KEY

Meaning:

1
2
3
4
...

No duplicates.

No NULLs.

Try:

INSERT INTO users (id, name)
VALUES (1, 'Bob');

You should get:

duplicate key value violates unique constraint

Why Primary Keys Exist

Without a primary key:

John
John
John

Which John?

Nobody knows.

Primary key solves identity.

Rails Equivalent

Migration:

create_table :users do |t|
t.string :name
t.string :email
end

Rails automatically adds:

id

as the primary key.


Part 6: Constraints

Constraint = database rule.

Interviewers love this topic.

NOT NULL

Create:

CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);

Try:

INSERT INTO products(name)
VALUES(NULL);

Fails.

UNIQUE

CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE
);

Duplicate email:

INSERT INTO customers(email)
VALUES('test@test.com');
INSERT INTO customers(email)
VALUES('test@test.com');

Fails.

CHECK Constraint

Age must be positive.

CREATE TABLE employees (
id BIGSERIAL PRIMARY KEY,
age INTEGER CHECK(age > 0)
);

Fails:

INSERT INTO employees(age)
VALUES(-5);

Why Constraints Matter

Junior developer:

validates :email, uniqueness: true

Senior developer:

validates :email, uniqueness: true
+
UNIQUE(email)

Because application validations can be bypassed.

Database constraints cannot.


Part 7: Foreign Keys

Now let’s model:

User has many orders.

Create users:

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100)
);

Create orders:

CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
total NUMERIC(10,2)
);

Foreign key:

ALTER TABLE orders
ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id);

Insert user:

INSERT INTO users(name)
VALUES('John');

Insert order:

INSERT INTO orders(user_id,total)
VALUES(1,100);

Works.

Try:

INSERT INTO orders(user_id,total)
VALUES(999,100);

Fails.

Because user doesn’t exist.

Why Foreign Keys Exist

Without them:

Order belongs to user 999

But user 999 doesn’t exist.

Database becomes corrupted.

Rails Equivalent

class User < ApplicationRecord
has_many :orders
end
class Order < ApplicationRecord
belongs_to :user
end

Migration:

t.references :user,
null: false,
foreign_key: true

Rails creates:

user_id
FOREIGN KEY

behind the scenes.


Part 8: One-to-Many Relationship

Most common relationship.

Example:

User -> Orders

One user:

John

Many orders:

Order 1
Order 2
Order 3

Diagram:

users
-----
id
orders
------
id
user_id

Rails:

User has_many :orders
Order belongs_to :user

Practical Exercise

Insert:

INSERT INTO users(name)
VALUES('Mary');

Orders:

INSERT INTO orders(user_id,total)
VALUES
(2,50),
(2,75),
(2,120);

Query:

SELECT *
FROM orders
WHERE user_id = 2;

Part 9: One-to-One Relationship

Less common.

Example:

User
Profile

Each user has exactly one profile.

Create profile table:

CREATE TABLE profiles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT UNIQUE,
bio TEXT,
FOREIGN KEY(user_id)
REFERENCES users(id)
);

Notice:

UNIQUE(user_id)

This forces:

One user
One profile

Rails:

class User < ApplicationRecord
has_one :profile
end
class Profile < ApplicationRecord
belongs_to :user
end

Interview Question:

How does a database enforce one-to-one?

Answer:

FOREIGN KEY
+
UNIQUE

on the foreign key column.


Part 10: Many-to-Many Relationship

Classic interview topic.

Example:

Students
Courses

Student can enroll in many courses.

Course can have many students.

Create students:

CREATE TABLE students (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100)
);

Create courses:

CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(100)
);

Need a join table:

CREATE TABLE enrollments (
student_id BIGINT,
course_id BIGINT,
PRIMARY KEY(student_id, course_id),
FOREIGN KEY(student_id)
REFERENCES students(id),
FOREIGN KEY(course_id)
REFERENCES courses(id)
);

Diagram:

students
|
|
enrollments
|
|
courses

Rails

class Student < ApplicationRecord
has_many :enrollments
has_many :courses, through: :enrollments
end
class Course < ApplicationRecord
has_many :enrollments
has_many :students, through: :enrollments
end
class Enrollment < ApplicationRecord
belongs_to :student
belongs_to :course
end

Senior-Level Insight

Most Rails developers stop at:

has_many
belongs_to

Strong backend engineers understand:

Association
Foreign Key
Constraint
Index
Storage

That understanding helps you:

  • debug production issues
  • optimize queries
  • design schemas
  • answer interview questions confidently

Interview Questions

Try answering without looking.

Q1

Difference between:

PRIMARY KEY

and

UNIQUE

Q2

Can a table have multiple UNIQUE constraints?

Q3

Can a table have multiple PRIMARY KEYS?

Q4

How is a one-to-one relationship implemented in PostgreSQL?

Q5

Why should foreign keys exist even when Rails validations exist?

Q6

What problem does a join table solve?

Practical Lab (Run Everything)

Create a fresh database:

CREATE DATABASE interview_sql_day1;

Connect:

\c interview_sql_day1

Create:

users
profiles
orders
students
courses
enrollments

Insert sample data.

Then practice:

SELECT * FROM users;
SELECT * FROM orders;
SELECT * FROM profiles;
SELECT * FROM enrollments;

Try intentionally violating:

  • PRIMARY KEY
  • UNIQUE
  • NOT NULL
  • FOREIGN KEY

and observe PostgreSQL’s error messages.

A senior engineer learns a lot from database errors.


Homework

Exercise 1

Create:

authors
books

One author → many books

Add proper foreign keys.

Exercise 2

Create:

employees
employee_details

One-to-one relationship.

Exercise 3

Create:

movies
actors
movie_actors

Many-to-many relationship.

Insert:

  • 3 movies
  • 5 actors

Create relationships.

Exercise 4

For every relationship above, write the equivalent Rails models and associations.

Day 2 Preview

Next we’ll cover the foundation of everything in SQL:

SELECT Queries

Including:

  • SELECT
  • WHERE
  • ORDER BY
  • LIMIT
  • OFFSET
  • DISTINCT
  • IN
  • BETWEEN
  • LIKE
  • ILIKE
  • NULL handling

plus PostgreSQL execution behavior and ActiveRecord equivalents.

This is where real querying begins.

Happy Learning! 🚀