Coding 101

SQL for Beginners: How Relational Databases Actually Work Under the Hood

DD
Ankur Ishwar
10 min read Updated Sep 7, 2026
Beginner guide to SQL and relational databases

When I was hacking together my first backend on an old 8GB RAM laptop, I treated databases like glorified Excel sheets. I wrote queries with string concatenation, used SELECT * on every route, and never created a single index.

Everything worked fine with ten test users. Then my site got 12,000 records. Queries started taking 3.5 seconds. CPU pinned at 100 percent. My server crashed because it ran out of connection pools.

Most college labs in India still teach SQL like it is 1998: typing uppercase keywords into an ancient Oracle 8i terminal, memorizing definitions for viva exams, and walking away without understanding how disk I/O, tables, and joins work in production. Let us fix that right now.

What is a Relational Database?

SQL stands for Structured Query Language. A Relational Database Management System (RDBMS) like PostgreSQL, MySQL, or SQLite stores data in structured tables with strict column definitions and relationships enforced by primary and foreign keys.

Unlike flat JSON files or NoSQL document stores where every document can have different shapes, an RDBMS enforces schema integrity. If a column is defined as an integer, you cannot slip a string into it. If an order references user ID 42, that user must exist in the users table.

For 90 percent of real-world products (e-commerce apps, UPI payment gateways, SaaS platforms, booking portals), relational databases are the default industry backbone. If you want to understand how databases fit into full server architectures, review our guide to backend development for beginners.

Designing Tables: Primary Keys and Foreign Keys

Let us model a simple ordering platform. We need customers and orders. Here is how you write production-grade DDL (Data Definition Language) in PostgreSQL:

-- Create users table
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    full_name VARCHAR(120) NOT NULL,
    city VARCHAR(80) DEFAULT 'Bangalore',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Create orders table with foreign key reference
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    amount_inr NUMERIC(10, 2) NOT NULL CHECK (amount_inr > 0),
    status VARCHAR(30) DEFAULT 'pending',
    placed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

Notice the constraints here:

  • PRIMARY KEY: Uniquely identifies each row. Under the hood, the database automatically builds an index on this column so lookups by ID run in sub-millisecond time.
  • UNIQUE: Prevents two accounts from sharing the same email address.
  • FOREIGN KEY (user_id): Enforces referential integrity. If you try to create an order for a user ID that does not exist, the database rejects the write immediately.
  • ON DELETE CASCADE: If a user deletes their account, all their associated orders get cleaned up automatically rather than leaving orphaned records.
  • CHECK (amount_inr > 0): Guards against negative order totals before your application logic even touches the data.

Inserting and Querying Data: The Core CRUD Operations

Now let us add records and query them.

-- Inserting records
INSERT INTO users (email, full_name, city) VALUES
('rahul.verma@example.com', 'Rahul Verma', 'Pune'),
('priya.sharma@example.com', 'Priya Sharma', 'Bangalore'),
('amit.patel@example.com', 'Amit Patel', 'Mumbai');

INSERT INTO orders (user_id, amount_inr, status) VALUES
(1, 1499.00, 'completed'),
(1, 499.00, 'completed'),
(2, 2999.00, 'processing');

To read data back, you use the SELECT statement. Here is the golden rule for production code: never use SELECT * in production backend routes.

-- Bad practice: pulls every column across the wire, breaks if new columns added
SELECT * FROM orders WHERE status = 'completed';

-- Good practice: requests only necessary columns
SELECT id, amount_inr, placed_at
FROM orders
WHERE status = 'completed'
ORDER BY placed_at DESC
LIMIT 20;

When you query specific columns, the database engine reads fewer disk pages and sends less data across your network connection to your Node.js or Python process.

SQL Joins Demystified

College textbooks love drawing confusing overlapping circles for joins. Here is the practical code perspective: a JOIN combines columns from two tables based on a shared key.

1. INNER JOIN

Returns rows only when there is a match in both tables. If a user has zero orders, they will not appear in an INNER JOIN query.

SELECT 
    users.full_name,
    orders.id AS order_id,
    orders.amount_inr,
    orders.status
FROM users
INNER JOIN orders ON users.id = orders.user_id;

Rahul appears twice because he has two orders. Priya appears once. Amit has zero orders, so he is excluded from the result set.

2. LEFT JOIN (or LEFT OUTER JOIN)

Returns all records from the left table (users), along with matching records from the right table (orders). If there is no match, the columns from the right table return NULL.

SELECT 
    users.full_name,
    users.email,
    orders.id AS order_id,
    COALESCE(orders.amount_inr, 0) AS total_spent
FROM users
LEFT JOIN orders ON users.id = orders.user_id;

In this query, Amit Patel appears with order_id: NULL and total_spent: 0. This is exactly what you need when building user profile pages or financial summaries. If you are building backend endpoints that serve this data, check our complete back-end developer roadmap.

Aggregations and GROUP BY: Writing Analytics Queries

When your product manager asks: "What is the total revenue per city?", you do not pull 50,000 rows into JavaScript and loop through them with Array.reduce(). You let the database engine do the heavy lifting using aggregations.

SELECT 
    users.city,
    COUNT(orders.id) AS total_orders,
    SUM(orders.amount_inr) AS total_revenue_inr,
    ROUND(AVG(orders.amount_inr), 2) AS average_order_value
FROM users
INNER JOIN orders ON users.id = orders.user_id
WHERE orders.status = 'completed'
GROUP BY users.city
HAVING SUM(orders.amount_inr) > 1000
ORDER BY total_revenue_inr DESC;

Notice the distinction between WHERE and HAVING:

  • WHERE filters individual rows before aggregation happens.
  • HAVING filters grouped buckets after the calculations are performed.

The 100x Speedup: Indexes and B-Trees

This is where self-taught engineers separate themselves from casual tutorial watchers. When you query WHERE email = 'rahul@example.com' on a table without an index, the database performs a Sequential Scan (Seq Scan). It opens every single block on disk from row 1 to row 1,000,000.

On a table with 500,000 rows, a sequential scan can take 800ms. If five users hit that endpoint simultaneously, your CPU spikes.

When you create an index, the database maintains a balanced search tree (B-tree) on that column. The database searches the tree in O(log N) operations:

-- Create an index on the foreign key and status
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);

-- Inspect performance using EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 1;

Running EXPLAIN ANALYZE shows the exact execution plan: whether Postgres picked an Index Scan or a Sequential Scan, how many disk blocks were hit, and actual execution time in milliseconds. A properly indexed query drops from 400ms to 0.8ms.

Preventing SQL Injection: Never Concatenate Raw Strings

The number one vulnerability beginners introduce in Node.js or Python backend servers is string interpolation in SQL queries:

// DANGEROUS: High risk security vulnerability
const query = `SELECT * FROM users WHERE email = '${req.body.email}' AND password = '${req.body.password}'`;
await db.query(query);

If an attacker inputs ' OR 1=1 -- in the email field, the query evaluates to true for all rows, dumping your entire database or granting administrative access.

Always use parameterized queries. The database driver escapes input and treats user strings strictly as literal values, never executable SQL commands:

// SAFE: Parameterized query
const text = 'SELECT id, email, full_name FROM users WHERE email = $1';
const values = [req.body.email];
const result = await db.query(text, values);

For more architectural patterns on designing secure REST APIs, see our API fundamentals guide.

How to Practice SQL for Free

You do not need to buy any paid database licenses. You can run PostgreSQL locally or in the cloud without spending a single rupee:

  • Local Setup: Install PostgreSQL via homebrew (macOS) or apt (Ubuntu). Connect using free GUI tools like DBeaver or TablePlus instead of clunky legacy utilities.
  • Zero Cost Cloud: Spin up a free PostgreSQL database on Supabase or Neon. They offer generous free tiers that let you run real databases with zero credit card requirements.
  • Interactive Practice: Use platforms like LeetCode (Database problems) and SQLZoo to drill syntax until joins and group-by clauses become second nature.

Master these fundamentals, learn how to read execution plans, and you will stand out in technical interviews against applicants who only know how to click buttons inside an ORM.

Found this useful?
View all articles
Free Technical Interview Prep

Practicing for Engineering Interviews?

Skip the expensive coaching bootcamps and dry LeetCode memorization. Practice real production scenarios with instant turn-by-turn AI feedback on Frontend, Backend, System Design, and DSA.

Free Utilities

Recommended Developer Tools for this Topic

Explore all 25+ tools→

Keep Reading

Related Articles

Learn with Dropout Developer

Build real software with AI

Step-by-step learning paths, vibe coding tutorials, and certified developer programs designed for the modern engineer.