DEV Community

[Share] SQL - A Quick Intro

Originally posted on Methodox Wiki.

Overview

In this article, we’ll walk through the essentials of SQL using the SQLite dialect. We’ll start by creating a couple of sample tables and populating them with data. These tables will serve as the foundation for our examples in later sections. By the end of this overview, you will have a simple schema to work with and understand how to set the scene for common SQL operations.

-- Create a table for users
CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY,
    username TEXT NOT NULL,
    email TEXT NOT NULL,
    joined_date TEXT NOT NULL  -- stored as ISO-8601 strings in SQLite
);

-- Create a table for orders
CREATE TABLE IF NOT EXISTS orders (
    id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL,
    product TEXT NOT NULL,
    quantity INTEGER NOT NULL,
    order_date TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

-- Insert sample data into users
INSERT INTO users (username, email, joined_date) VALUES
    ('alice', '[email protected]', '2023-01-15'),
    ('bob', '[email protected]', '2023-02-10'),
    ('carol', '[email protected]', '2023-03-05');

-- Insert sample data into orders
INSERT INTO orders (user_id, product, quantity, order_date) VALUES
    (1, 'Keyboard', 2, '2023-04-01'),
    (1, 'Mouse', 1, '2023-04-07'),
    (2, 'Monitor', 1, '2023-04-15'),
    (3, 'Desk Lamp', 3, '2023-05-02'),
    (2, 'USB Cable', 5, '2023-05-10');
Enter fullscreen mode Exit fullscreen mode

These two tables - users and orders - will be the basis for our queries. Feel free to run these statements in an SQLite environment (e.g., the sqlite3 CLI or any GUI tool) to experiment with the examples that follow.

Common Syntax

In this section, we’ll introduce the four most common SQL statements:

  1. SELECT
  2. UPDATE
  3. DELETE
  4. INSERT

We’ll focus on the typical usage patterns for each statement without covering every possible option or keyword. All SQL keywords are shown in uppercase by convention - this is a good practice. However, they are usually case-insensitive to the SQL engine.

1. SELECT

The SELECT statement is used to retrieve data from one or more tables. At its simplest, you specify the columns you want and the table from which to retrieve them:

SELECT id, username, email
FROM users;
Enter fullscreen mode Exit fullscreen mode

If you want all columns, use *:

SELECT *
FROM orders;
Enter fullscreen mode Exit fullscreen mode

You can filter results using WHERE:

SELECT username, email
FROM users
WHERE joined_date >= '2023-02-01';
Enter fullscreen mode Exit fullscreen mode

To sort results, use ORDER BY:

SELECT id, product, quantity, order_date
FROM orders
WHERE quantity > 1
ORDER BY order_date DESC;
Enter fullscreen mode Exit fullscreen mode

Limit the number of rows returned:

SELECT *
FROM orders
LIMIT 3;
Enter fullscreen mode Exit fullscreen mode

Combine clauses:

SELECT username, email
FROM users
WHERE username LIKE 'a%'
ORDER BY joined_date;
Enter fullscreen mode Exit fullscreen mode

The LIKE keyword here matches all names starting with an a.

2. UPDATE

Use UPDATE to modify existing rows. Always include a WHERE clause to avoid updating every row unintentionally:

-- Change Bob's email address
UPDATE users
SET email = '[email protected]'
WHERE username = 'bob';
Enter fullscreen mode Exit fullscreen mode

You can update multiple columns at once:

UPDATE users
SET email = '[email protected]',
    joined_date = '2023-01-20'
WHERE username = 'alice';
Enter fullscreen mode Exit fullscreen mode

It’s good practice to run a SELECT with the same WHERE first:

SELECT * FROM users WHERE username = 'alice';
-- Review the row, then:
UPDATE users
SET email = '[email protected]'
WHERE username = 'alice';
Enter fullscreen mode Exit fullscreen mode

3. DELETE

The DELETE statement removes rows from a table. Again, a WHERE clause is essential:

-- Delete Carol's record
DELETE FROM users
WHERE username = 'carol';
Enter fullscreen mode Exit fullscreen mode

To delete all rows in a table (but keep the table structure):

DELETE FROM orders;
Enter fullscreen mode Exit fullscreen mode

SQLite supports DELETE FROM table_name; without WHERE to remove every row. If you only want to remove specific rows:

DELETE FROM orders
WHERE order_date < '2023-04-01';
Enter fullscreen mode Exit fullscreen mode

4. INSERT

INSERT adds new rows into a table. We already used this in the overview to populate users and orders. The basic syntax specifies columns and values:

INSERT INTO users (username, email, joined_date)
VALUES ('dave', '[email protected]', '2023-06-01');
Enter fullscreen mode Exit fullscreen mode

You can omit the column list if you supply values for every column in the exact order:

INSERT INTO orders VALUES (NULL, 4, 'Webcam', 2, '2023-06-02');
-- NULL for id since it's an AUTOINCREMENT primary key
Enter fullscreen mode Exit fullscreen mode

If you want to insert from another table (a simple form of SELECT inside INSERT):

INSERT INTO orders (user_id, product, quantity, order_date)
SELECT id, 'Headphones', 1, '2023-06-05'
FROM users
WHERE username = 'bob';
Enter fullscreen mode Exit fullscreen mode

Techniques

The examples above covered basic CRUD operations. In this section, we’ll focus on more advanced - but frequently used - SELECT patterns in SQLite, including implicit joins, explicit joins, Common Table Expressions (WITH), and subqueries. These techniques are particularly helpful when you need to combine data from multiple tables or derive aggregated results.

1. Implicit Join Using Multiple Tables in FROM

An implicit join (sometimes called a “comma join”) lists multiple tables in the FROM clause and specifies the join condition in WHERE. This style is concise but can be less clear for complex queries:

-- List every order along with the username of the user who placed it
SELECT u.username, o.product, o.quantity, o.order_date
FROM users u, orders o
WHERE u.id = o.user_id;
Enter fullscreen mode Exit fullscreen mode

Here, u is an alias for users and o is an alias for orders. The join condition u.id = o.user_id ties rows in users to matching rows in orders.

You can add filters:

-- Only orders with quantity > 1
SELECT u.username, o.product, o.quantity
FROM users u, orders o
WHERE u.id = o.user_id
  AND o.quantity > 1
ORDER BY o.order_date;
Enter fullscreen mode Exit fullscreen mode

2. Explicit Join Syntax (INNER JOIN, LEFT JOIN)

SQLite supports the standard explicit JOIN syntax, which is recommended for clarity:

-- INNER JOIN retrieves only matching rows
SELECT u.username, o.product, o.order_date
FROM users u
INNER JOIN orders o
    ON u.id = o.user_id;
Enter fullscreen mode Exit fullscreen mode

For a left (or left outer) join, which returns all rows from the left table and matching rows (if any) from the right table:

-- List all users, including those who have not placed orders
SELECT u.username, o.product, o.order_date
FROM users u
LEFT JOIN orders o
    ON u.id = o.user_id;
Enter fullscreen mode Exit fullscreen mode

If a user has no matching orders, the product and order_date columns will be NULL.

Multiple Joins
When you have more than two tables, chain the joins together:

-- Suppose we add a table for payment info
CREATE TABLE IF NOT EXISTS payments (
    id INTEGER PRIMARY KEY,
    order_id INTEGER NOT NULL,
    amount REAL NOT NULL,
    payment_date TEXT NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id)
);

-- Sample data
INSERT INTO payments (order_id, amount, payment_date) VALUES
    (1, 49.98, '2023-04-02'),
    (2, 19.99, '2023-04-08'),
    (3, 149.99, '2023-04-16');

-- Query: find username, product, and payment amount for each paid order
SELECT u.username,
       o.product,
       p.amount,
       p.payment_date
FROM users u
INNER JOIN orders o
    ON u.id = o.user_id
INNER JOIN payments p
    ON o.id = p.order_id
ORDER BY p.payment_date;
Enter fullscreen mode Exit fullscreen mode

3. WITH Clause (Common Table Expressions)

A Common Table Expression (CTE) defined by WITH allows you to create a temporary, named result set that you can reference in a subsequent query. This is useful for breaking complex queries into more readable pieces.

-- Example: Find users who have spent more than $50 in total
WITH user_totals AS (
    SELECT u.id AS user_id,
           u.username,
           SUM(p.amount) AS total_spent
    FROM users u
    INNER JOIN orders o
        ON u.id = o.user_id
    INNER JOIN payments p
        ON o.id = p.order_id
    GROUP BY u.id, u.username
)
SELECT username, total_spent
FROM user_totals
WHERE total_spent > 50;
Enter fullscreen mode Exit fullscreen mode

In this example:

  1. The CTE named user_totals aggregates total spending per user.
  2. The outer SELECT filters users based on their aggregated spending.

CTEs can be recursive as well, but for a quick intro, focus on non-recursive CTEs.

4. Subqueries

Subqueries (also called inner queries or nested queries) are queries embedded inside other SQL statements. They can appear in the SELECT list, FROM clause, or WHERE clause.

4.1 Subquery in SELECT List

-- Add a column showing the number of orders each user has placed
SELECT u.username,
       u.email,
       (
           SELECT COUNT(*)
           FROM orders o
           WHERE o.user_id = u.id
       ) AS order_count
FROM users u;
Enter fullscreen mode Exit fullscreen mode

The subquery (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) executes for each row in users.

4.2 Subquery in WHERE Clause

-- Find users who have never placed an order
SELECT username, email
FROM users
WHERE id NOT IN (
    SELECT DISTINCT user_id
    FROM orders
);
Enter fullscreen mode Exit fullscreen mode

Alternatively, using NOT EXISTS:

SELECT username, email
FROM users u
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);
Enter fullscreen mode Exit fullscreen mode

4.3 Subquery in FROM Clause

-- Join users to a derived table that calculates order counts
SELECT t.username, t.order_count
FROM (
    SELECT u.id AS user_id,
           u.username,
           COUNT(o.id) AS order_count
    FROM users u
    LEFT JOIN orders o
        ON u.id = o.user_id
    GROUP BY u.id, u.username
) AS t
WHERE t.order_count >= 2;
Enter fullscreen mode Exit fullscreen mode

Here, the subquery in the FROM clause (aliased as t) computes the number of orders per user. The outer query then filters on order_count.

5. Aggregation and GROUP BY

While technically a part of SELECT patterns, aggregation is so common that it merits a dedicated mention:

-- Total quantity ordered per product
SELECT product,
       SUM(quantity) AS total_quantity
FROM orders
GROUP BY product
ORDER BY total_quantity DESC;
Enter fullscreen mode Exit fullscreen mode

HAVING

To filter on aggregated values, use HAVING rather than WHERE:

-- Only products with total quantity > 2
SELECT product,
       SUM(quantity) AS total_quantity
FROM orders
GROUP BY product
HAVING total_quantity > 2;
Enter fullscreen mode Exit fullscreen mode

6. Putting It All Together

Let’s combine a few of these techniques into a more comprehensive example:

  1. We want to list each user, their total number of orders, total quantity ordered, and their most recent order date.
  2. Only include users who have placed at least one order.
  3. Sort by total quantity descending.
WITH user_orders AS (
    SELECT u.id    AS user_id,
           u.username,
           COUNT(o.id)           AS order_count,
           SUM(o.quantity)       AS total_quantity,
           MAX(o.order_date)     AS last_order_date
    FROM users u
    INNER JOIN orders o
        ON u.id = o.user_id
    GROUP BY u.id, u.username
)
SELECT username,
       order_count,
       total_quantity,
       last_order_date
FROM user_orders
WHERE order_count >= 1
ORDER BY total_quantity DESC;
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • The CTE user_orders aggregates order-related metrics per user.
  • The outer query filters out users with zero orders and sorts the results.

Putting It into Practice

Here are a few additional examples and variations you might find useful in everyday data analysis with SQLite:

  1. Top 3 Users by Total Spending Combine payments and orders to rank users.
   WITH spending AS (
       SELECT u.id         AS user_id,
              u.username,
              SUM(p.amount) AS total_spent
       FROM users u
       INNER JOIN orders o
           ON u.id = o.user_id
       INNER JOIN payments p
           ON o.id = p.order_id
       GROUP BY u.id, u.username
   )
   SELECT username, total_spent
   FROM spending
   ORDER BY total_spent DESC
   LIMIT 3;
Enter fullscreen mode Exit fullscreen mode
  1. Find Orders with No Payment Records Use a left join and look for NULL in the payment side.
   SELECT o.id AS order_id,
          u.username,
          o.product,
          o.order_date
   FROM orders o
   LEFT JOIN payments p
       ON o.id = p.order_id
   INNER JOIN users u
       ON o.user_id = u.id
   WHERE p.id IS NULL;
Enter fullscreen mode Exit fullscreen mode
  1. Update via Subquery Suppose we want to mark users who joined before a certain date as “legacy” in a new column. First, add a column:
   ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';

   UPDATE users
   SET status = 'legacy'
   WHERE joined_date < '2023-02-01';
Enter fullscreen mode Exit fullscreen mode
  1. Delete Based on a Joined Condition If you needed to delete payments for orders that were canceled (imagine a hypothetical orders.status = 'canceled' column), you could write:
   DELETE FROM payments
   WHERE order_id IN (
       SELECT id
       FROM orders
       WHERE status = 'canceled'
   );
Enter fullscreen mode Exit fullscreen mode
  1. Nested CTEs Chain multiple CTEs to break down a complex problem:
   WITH recent_orders AS (
       SELECT *
       FROM orders
       WHERE order_date >= '2023-04-01'
   ),
   recent_payments AS (
       SELECT o.id     AS order_id,
              o.user_id,
              p.amount,
              p.payment_date
       FROM recent_orders o
       INNER JOIN payments p
           ON o.id = p.order_id
   )
   SELECT u.username,
          SUM(rp.amount) AS recent_spending
   FROM users u
   INNER JOIN recent_payments rp
       ON u.id = rp.user_id
   GROUP BY u.username
   ORDER BY recent_spending DESC;
Enter fullscreen mode Exit fullscreen mode

Conclusion

This article has introduced the basics of SQL using the SQLite dialect, focusing on the most common statements - SELECT, UPDATE, DELETE, and INSERT - as well as several useful SELECT techniques. You now have a schema with users, orders, and payments to experiment with, and you’ve seen how to:

  • Create tables and insert data.
  • Perform simple CRUD operations.
  • Join tables both implicitly and explicitly.
  • Use Common Table Expressions (WITH).
  • Write subqueries in various parts of a statement.
  • Aggregate data and filter with HAVING.

These examples should provide a solid foundation for everyday data analysis tasks. As you become more comfortable, you can explore advanced topics like window functions, triggers, and indexing strategies in SQLite.

Top comments (0)