How to Query Data from a MySQL Database

How to Query Data from a MySQL Database

If installing MySQL is the foundation, then querying data is the room I actually spend most of my time living in. Whether I’m building a reporting dashboard, debugging a slow endpoint, or exploring a client’s data for the first time, SELECT is the statement I write more than any other. In this guide, I’ll go from the most basic query all the way to how MySQL’s optimizer actually decides how to execute what I write.

How MySQL Processes a SELECT Query Internally

Before diving into syntax, I want to explain what happens the moment I hit enter on a query:

  1. Parser – converts my SQL text into a parse tree, checking for syntax errors.
  2. Preprocessor – resolves table and column names, checks privileges.
  3. Optimizer – decides the most efficient execution plan: which indexes to use, join order, and access method.
  4. Execution Engine – calls down into the storage engine (InnoDB) to actually fetch rows.
  5. Result Set – rows are returned to the client, optionally sorted or grouped along the way.
graph TD
    A[SQL Query Text] --> B[Parser]
    B --> C[Preprocessor: Name Resolution & Privileges]
    C --> D[Optimizer: Chooses Execution Plan]
    D --> E[Execution Engine]
    E --> F[Storage Engine: InnoDB]
    F --> G[Result Set Returned to Client]

Step 1: The Most Basic SELECT

SELECT * FROM customers;

I actually avoid SELECT * in real application code — I only use it for quick, interactive exploration — because it pulls unnecessary columns, increases network overhead, and can silently break code if the table schema changes.

SELECT id, full_name, email FROM customers;

Filtering With WHERE

SELECT full_name, email
FROM customers
WHERE created_at >= '2026-01-01';

I combine conditions using AND/OR, and I’m always deliberate about operator precedence:

SELECT * FROM orders
WHERE (status = 'pending' OR status = 'shipped')
AND order_total > 100;

Sorting Results

SELECT full_name, created_at
FROM customers
ORDER BY created_at DESC
LIMIT 10;

I use LIMIT constantly, especially when previewing large tables — running SELECT * on a multi-million row table without a limit is a mistake I made exactly once early in my career, and never again.

Aggregating Data

SELECT status, COUNT(*) AS total_orders, SUM(order_total) AS revenue
FROM orders
GROUP BY status;

Sample output:

+-----------+--------------+---------+
| status    | total_orders | revenue |
+-----------+--------------+---------+
| pending   | 42           | 5230.50 |
| shipped   | 120          | 18452.75|
| delivered | 980          | 152340.10|
+-----------+--------------+---------+

I frequently pair GROUP BY with HAVING when I need to filter on the aggregated result rather than the raw rows:

SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
HAVING order_count > 5;

Joining Tables

Joins are where I spend the most time reasoning carefully, since getting them wrong silently produces incorrect results rather than an error.

SELECT c.full_name, o.order_id, o.order_total
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
WHERE o.status = 'delivered';
graph LR
    A[customers table] -- INNER JOIN on id = customer_id --> B[orders table]
    B --> C[Result: matched rows only]
Join TypeBehaviorWhen I Use It
INNER JOINOnly matching rows in both tablesDefault choice for most relational queries
LEFT JOINAll rows from left table, matched or NULL from rightFinding customers with zero orders
RIGHT JOINAll rows from right table, matched or NULL from leftRarely used; I usually rewrite as LEFT JOIN
CROSS JOINCartesian product of both tablesGenerating combinations, rare in production

Example of a LEFT JOIN I use often to find customers who never placed an order:

SELECT c.full_name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.order_id IS NULL;

Subqueries and Derived Tables

SELECT full_name
FROM customers
WHERE id IN (
    SELECT customer_id FROM orders WHERE order_total > 500
);

I often rewrite subqueries as joins when performance matters, since MySQL’s optimizer historically handled certain correlated subqueries less efficiently than equivalent joins, though this has improved significantly in MySQL 8.0’s optimizer.

Common Table Expressions (CTEs)

Since MySQL 8.0, I use CTEs heavily for readability, especially with recursive hierarchies:

WITH high_value_customers AS (
    SELECT customer_id, SUM(order_total) AS total_spent
    FROM orders
    GROUP BY customer_id
    HAVING total_spent > 1000
)
SELECT c.full_name, h.total_spent
FROM customers c
JOIN high_value_customers h ON c.id = h.customer_id
ORDER BY h.total_spent DESC;

A recursive CTE example I’ve used for category trees:

WITH RECURSIVE category_tree AS (
    SELECT id, name, parent_id FROM categories WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.name, c.parent_id
    FROM categories c
    JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT * FROM category_tree;

Window Functions

Window functions changed the way I write analytical queries entirely, since MySQL 8.0 introduced them:

SELECT
    customer_id,
    order_total,
    RANK() OVER (PARTITION BY customer_id ORDER BY order_total DESC) AS rank_within_customer
FROM orders;

I use ROW_NUMBER(), RANK(), LAG(), and LEAD() constantly for things like finding a customer’s most recent order or computing running totals:

SELECT
    order_id,
    order_total,
    SUM(order_total) OVER (ORDER BY created_at) AS running_total
FROM orders;

Reading an EXPLAIN Plan

Whenever a query feels slow, the very first thing I do is prepend EXPLAIN:

EXPLAIN SELECT c.full_name, o.order_total
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE o.status = 'pending';

Sample output:

+----+-------------+-------+------+---------------+---------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key     | key_len | ref  | rows | Extra       |
+----+-------------+-------+------+---------------+---------+---------+------+------+-------------+
| 1  | SIMPLE      | o     | ref  | idx_status    | idx_status | 1    | const| 42   | Using where |
| 1  | SIMPLE      | c     | eq_ref | PRIMARY     | PRIMARY | 4       | o.customer_id | 1 | |
+----+-------------+-------+------+---------------+---------+---------+------+------+-------------+

I check the type column closely — ALL means a full table scan (a red flag on large tables), while ref, eq_ref, or const indicate the optimizer found a usable index.

A Real-World Scenario: Building a Sales Dashboard Query

For a client’s monthly sales dashboard, I needed total revenue, order count, and average order value per month:

SELECT
    DATE_FORMAT(created_at, '%Y-%m') AS month,
    COUNT(*) AS total_orders,
    SUM(order_total) AS total_revenue,
    ROUND(AVG(order_total), 2) AS avg_order_value
FROM orders
WHERE status != 'cancelled'
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month;

I initially wrote this with a function wrapped around created_at in the WHERE clause too, but I moved that filter to a plain range condition since wrapping an indexed column in a function prevents MySQL from using the index on that column at all.

Security Considerations When Querying

Troubleshooting Common Query Issues

Issue: Query returns no rows unexpectedly

I check for NULL comparison mistakes — WHERE column = NULL never matches anything; I need WHERE column IS NULL.

Issue: Query is very slow on a large table

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 12345;

I check whether an index exists on customer_id, and if not, I create one (covered in more depth in my indexing guide).

Issue: “Unknown column” error in JOIN

I double-check I’m not accidentally referencing an ambiguous column name that exists in both joined tables without a table alias prefix.

Performance Best Practices for Querying

Frequently Asked Questions

Q: What’s the difference between WHERE and HAVING? A: WHERE filters rows before grouping; HAVING filters groups after aggregation.

Q: Why is SELECT * considered bad practice? A: It transfers unnecessary data, prevents certain index-only optimizations (covering indexes), and can break code silently on schema changes.

Q: How do I paginate efficiently in MySQL? A: I use keyset pagination for large tables:

SELECT * FROM orders WHERE order_id > 50000 ORDER BY order_id LIMIT 20;

This avoids the performance degradation of LIMIT 50000, 20.

Q: What’s a covering index? A: An index that contains all the columns a query needs, letting MySQL satisfy the query directly from the index without touching the table data at all.

Interview Questions I’ve Encountered

  1. Explain the difference between INNER JOIN, LEFT JOIN, and CROSS JOIN.
  2. What does the type column in an EXPLAIN plan tell you, and what values should concern you?
  3. Why does wrapping an indexed column in a function prevent index usage?
  4. What is a covering index, and how would you design one?
  5. How would you implement efficient pagination for a table with 50 million rows?
  6. Explain how a window function like RANK() OVER (PARTITION BY ...) differs from GROUP BY.

Summary and Key Takeaways

Querying data is where I spend the majority of my time as a developer and DBA, and the difference between a naive query and a well-optimized one often comes down to understanding indexes, join strategy, and how the MySQL optimizer thinks. EXPLAIN has been the single most valuable habit I’ve built into my daily workflow.

Key takeaways:

References

Exit mobile version