Loading content...
Data analysis often hinges on identifying boundaries. What's the highest salary? The earliest order date? The lowest-rated product? The most expensive transaction? These questions seek extreme values—the minimum and maximum points that define the range of your data.
MIN() and MAX() are SQL's tools for finding these extremes. While conceptually simple, they possess nuances that distinguish expert usage from naive queries—particularly regarding NULL handling, data type behavior, and performance optimization. Understanding these functions deeply enables efficient boundary detection, range analysis, and data validation.
By the end of this page, you will understand MIN and MAX semantics across different data types, their NULL handling behavior, performance characteristics with indexes, and practical patterns for range analysis, gap detection, and boundary validation.
MIN() returns the smallest value in a set, while MAX() returns the largest. Unlike COUNT, SUM, and AVG, these functions don't combine values mathematically—they select existing values based on ordering.
Basic syntax:
MIN(expression)
MAX(expression)
The expression can be a column, a computed expression, or any value that supports ordering comparison.
123456789101112131415161718192021222324252627
-- Basic minimum and maximumSELECT MIN(salary) AS lowest_salary, MAX(salary) AS highest_salaryFROM employees; -- Range calculationSELECT MIN(salary) AS min_sal, MAX(salary) AS max_sal, MAX(salary) - MIN(salary) AS salary_rangeFROM employees; -- Multiple MIN/MAX in one querySELECT MIN(hire_date) AS earliest_hire, MAX(hire_date) AS latest_hire, MIN(salary) AS min_salary, MAX(salary) AS max_salaryFROM employeesWHERE department = 'Engineering'; -- MIN/MAX with expressionsSELECT MIN(quantity * unit_price) AS smallest_order, MAX(quantity * unit_price) AS largest_orderFROM order_items;Unlike SUM and AVG, MIN and MAX return the same data type as the input. If you MIN a DATE column, you get a DATE. If you MAX an INTEGER, you get an INTEGER. They select actual values rather than computing new ones.
The ordering semantics:
MIN and MAX rely on the database's ordering rules for the data type:
| Data Type | MIN Returns | MAX Returns |
|---|---|---|
| Numeric | Smallest number | Largest number |
| String | First in collation order | Last in collation order |
| Date/Time | Earliest date/time | Latest date/time |
| Boolean | FALSE (if exists) | TRUE (if exists) |
For strings, "smallest" and "largest" depend on the collation (sorting rules). In case-sensitive collation, 'Z' < 'a' because uppercase letters sort before lowercase in ASCII/Unicode.
Like all standard aggregate functions, MIN and MAX ignore NULL values. NULLs are excluded from consideration when determining extremes.
This behavior makes sense philosophically: NULL means "unknown," and we can't determine if an unknown value is greater or less than known values.
| Data | MIN Result | MAX Result | Notes |
|---|---|---|---|
| 100, 200, 300 | 100 | 300 | Standard behavior |
| 100, NULL, 300 | 100 | 300 | NULL ignored |
| NULL, NULL, NULL | NULL | NULL | All NULL returns NULL |
| (empty set) | NULL | NULL | No rows returns NULL |
1234567891011121314151617181920212223242526272829
-- Sample data with NULLs-- salaries: 100, NULL, 300, 200, NULL SELECT MIN(salary) AS min_sal, -- 100 (NULLs ignored) MAX(salary) AS max_sal -- 300 (NULLs ignored)FROM employees; -- Empty set behaviorSELECT MIN(salary) AS min_sal, -- NULL MAX(salary) AS max_sal -- NULLFROM employeesWHERE 1 = 0; -- No matching rows -- Safe handling with COALESCESELECT COALESCE(MIN(salary), 0) AS safe_min, COALESCE(MAX(salary), 0) AS safe_maxFROM employeesWHERE department = 'NonExistent'; -- Checking for complete NULL dataSELECT CASE WHEN MIN(salary) IS NULL THEN 'No salary data' ELSE 'Salary data exists' END AS data_statusFROM employees;If you want to treat NULL as a specific value (like 0 for MIN or infinity for MAX), wrap the column in COALESCE: MIN(COALESCE(column, 0)) treats NULLs as 0s. However, this changes the semantics significantly—use with caution.
MIN and MAX work across all comparable data types, but their behavior varies based on the type's ordering rules. Understanding these variations is essential for correct usage.
Numeric types:
For numbers, MIN and MAX follow natural mathematical ordering. Negative numbers are less than positive, and decimal precision is preserved.
12345678910111213
-- Numeric MIN/MAXSELECT MIN(temperature) AS coldest, -- Handles negatives correctly MAX(temperature) AS hottestFROM weather_data;-- If data is -10, 5, 15: MIN = -10, MAX = 15 -- Decimal precisionSELECT MIN(price) AS lowest_price, -- Preserves decimal precision MAX(price) AS highest_priceFROM products;-- If data is 9.99, 19.95, 29.99: MIN = 9.99, MAX = 29.99String types:
String ordering depends on the collation (locale-specific sorting rules). This can lead to surprising results if you're not careful.
1234567891011121314151617181920212223
-- String MIN/MAX follows collation orderSELECT MIN(name) AS first_alphabetically, MAX(name) AS last_alphabeticallyFROM employees;-- Depends on collation: case-sensitive vs case-insensitive -- Case sensitivity example (ASCII/binary collation)-- Values: 'Apple', 'banana', 'CHERRY'-- MIN = 'Apple' (A=65 < C=67 < b=98 in ASCII)-- MAX = 'banana' (b=98 > all uppercase letters in ASCII)-- This may surprise you! -- Case-insensitive collation gives expected alphabetical orderSELECT MIN(name COLLATE SQL_Latin1_General_CP1_CI_AS) AS first_name, MAX(name COLLATE SQL_Latin1_General_CP1_CI_AS) AS last_nameFROM products; -- SQL Server syntax -- PostgreSQL: specify collationSELECT MIN(name COLLATE "en_US.utf8") AS first_nameFROM products;If numeric values are stored as strings, MIN and MAX compare them lexicographically: '9' > '10' because '9' > '1'. Similarly, '2' > '100'. Always use proper numeric types for numbers, or CAST before comparing.
12345678910111213141516171819
-- Date/Time types (chronological ordering)SELECT MIN(order_date) AS first_order, MAX(order_date) AS most_recent_orderFROM orders; -- Timestamp precisionSELECT MIN(created_at) AS earliest_timestamp, MAX(updated_at) AS latest_activityFROM audit_log; -- Date range analysisSELECT MIN(birth_date) AS oldest_employee, MAX(birth_date) AS youngest_employee, AGE(MIN(birth_date)) AS oldest_age, AGE(MAX(birth_date)) AS youngest_age -- PostgreSQL AGE functionFROM employees;Boolean types:
Boolean MIN/MAX treats FALSE < TRUE (0 < 1). This is occasionally useful for checking if ANY or ALL values are true:
1234567891011121314151617
-- Boolean MIN/MAX (in databases that support it)SELECT MIN(is_active) AS any_inactive, -- FALSE if any row is FALSE MAX(is_active) AS any_active -- TRUE if any row is TRUEFROM accounts; -- More common: use BOOL_AND / BOOL_OR (PostgreSQL)SELECT BOOL_AND(is_verified) AS all_verified, -- TRUE only if ALL are TRUE BOOL_OR(is_suspended) AS any_suspended -- TRUE if ANY is TRUEFROM users; -- SQL Server equivalent using MIN/MAXSELECT CASE MIN(CAST(is_active AS INT)) WHEN 0 THEN 'Some Inactive' ELSE 'All Active' END, CASE MAX(CAST(is_premium AS INT)) WHEN 1 THEN 'Has Premium' ELSE 'No Premium' ENDFROM customers;MIN and MAX have unique performance characteristics among aggregate functions. Because they seek a single extreme value rather than processing all values, they can often be optimized using indexes.
Index optimization for MIN/MAX:
When an index exists on the column being aggregated, the database can often find MIN or MAX by simply reading the first or last entry in the index—an O(1) operation instead of O(n) full table scan.
This optimization applies when:
SELECT MIN(indexed_col) FROM table or SELECT MAX(indexed_col) FROM table12345678910111213141516171819202122
-- Fast: Uses index efficiently-- Assuming index on 'created_at'SELECT MAX(created_at) FROM orders;-- Execution: Index scan to last entry, O(1) or O(log n) -- Also fast: Simple WHERE with indexed columnSELECT MIN(order_date) FROM orders WHERE customer_id = 12345;-- If (customer_id, order_date) index exists, very efficient -- Slower: Complex WHERE may prevent optimizationSELECT MIN(price) FROM products WHERE LOWER(name) LIKE '%widget%';-- LIKE with leading wildcard prevents index use on 'name'-- Function on column (LOWER) may prevent index use -- Slower: GROUP BY requires finding MIN/MAX per groupSELECT customer_id, MIN(order_date), MAX(order_date)FROM ordersGROUP BY customer_id;-- Must process each group, though indexes still help -- Check execution plan to verify optimizationEXPLAIN ANALYZE SELECT MAX(created_at) FROM orders;Use EXPLAIN or EXPLAIN ANALYZE to verify MIN/MAX optimization. Look for 'Index Only Scan' or 'Index Scan with LIMIT' patterns indicating the query isn't scanning all rows.
| Scenario | Without Index | With Index | Notes |
|---|---|---|---|
| Simple MIN/MAX | O(n) - full scan | O(1) or O(log n) | Index boundary access |
| With WHERE (indexed) | O(n) - full scan | O(m log n) where m matches | Index seek + boundary |
| With GROUP BY | O(n log g) | O(n) or better | g = number of groups |
| Expression MIN/MAX | O(n) - always | O(n) - can't use index | Functions prevent optimization |
12345678910111213141516171819202122232425
-- Optimization: Combined MIN/MAX in single scanSELECT MIN(price), MAX(price) FROM products;-- Database processes table once, tracking both -- Anti-pattern: Separate queriesSELECT MIN(price) FROM products;SELECT MAX(price) FROM products;-- Two separate scans (unless cached) -- Optimization: Correlated scalar subquery for "row with MAX"-- Find the employee with highest salarySELECT *FROM employeesWHERE salary = (SELECT MAX(salary) FROM employees); -- Or use window function (often more efficient)SELECT *FROM ( SELECT *, MAX(salary) OVER () AS max_sal FROM employees) subWHERE salary = max_sal; -- Or ORDER BY with LIMIT (very efficient with index)SELECT * FROM employees ORDER BY salary DESC LIMIT 1;Combining MIN/MAX with GROUP BY enables segmented extreme analysis—finding minimums and maximums within each category, time period, or other dimensional slice.
123456789101112131415161718192021222324252627282930
-- Salary range by departmentSELECT department_id, MIN(salary) AS min_salary, MAX(salary) AS max_salary, MAX(salary) - MIN(salary) AS salary_spreadFROM employeesGROUP BY department_idORDER BY salary_spread DESC; -- Date range by customer (account lifespan)SELECT customer_id, MIN(order_date) AS first_order, MAX(order_date) AS last_order, MAX(order_date) - MIN(order_date) AS days_between_ordersFROM ordersGROUP BY customer_idHAVING COUNT(*) > 1; -- Only customers with multiple orders -- Price extremes by category and yearSELECT category, EXTRACT(YEAR FROM sale_date) AS year, MIN(unit_price) AS lowest_price, MAX(unit_price) AS highest_price, AVG(unit_price) AS avg_priceFROM salesGROUP BY category, EXTRACT(YEAR FROM sale_date)ORDER BY category, year;Finding the row containing MIN/MAX values:
A common challenge is retrieving the entire row that contains the minimum or maximum value, not just the extreme value itself.
12345678910111213141516171819202122232425262728293031323334
-- Goal: Find the full record of highest-paid employee per department -- Approach 1: Correlated subquery (portable but can be slow)SELECT *FROM employees e1WHERE salary = ( SELECT MAX(e2.salary) FROM employees e2 WHERE e2.department_id = e1.department_id); -- Approach 2: Join with aggregated subquerySELECT e.*FROM employees eJOIN ( SELECT department_id, MAX(salary) AS max_salary FROM employees GROUP BY department_id) m ON e.department_id = m.department_id AND e.salary = m.max_salary; -- Approach 3: Window function (usually most efficient)SELECT *FROM ( SELECT *, RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk FROM employees) rankedWHERE rnk = 1; -- Approach 4: DISTINCT ON (PostgreSQL specific)SELECT DISTINCT ON (department_id) *FROM employeesORDER BY department_id, salary DESC;If multiple rows share the MIN or MAX value, approaches 1 and 2 return all ties. Approach 3 with RANK also returns ties. To get exactly one row per group, use ROW_NUMBER() instead of RANK(), or DISTINCT ON in PostgreSQL.
MIN and MAX appear in numerous practical patterns beyond simple extreme finding. Let's explore common use cases.
123456789101112131415161718192021222324252627282930
-- Pattern: Range validation-- Check if values fall within expected boundsSELECT CASE WHEN MIN(quantity) < 0 THEN 'Invalid: Negative quantities found' WHEN MAX(quantity) > 10000 THEN 'Warning: Unusually large quantities' ELSE 'All quantities within expected range' END AS validation_resultFROM order_items; -- Pattern: Gap detection-- Find missing numbers in a sequenceSELECT MIN(id) AS first_id, MAX(id) AS last_id, COUNT(*) AS actual_count, MAX(id) - MIN(id) + 1 AS expected_count, (MAX(id) - MIN(id) + 1) - COUNT(*) AS gap_countFROM invoice_ids; -- Pattern: Freshness check-- How recent is our data?SELECT MAX(updated_at) AS last_update, CURRENT_TIMESTAMP - MAX(updated_at) AS time_since_update, CASE WHEN MAX(updated_at) < CURRENT_TIMESTAMP - INTERVAL '1 hour' THEN 'STALE' ELSE 'FRESH' END AS data_statusFROM sensor_readings;12345678910111213141516171819202122232425262728293031
-- Pattern: Generating sequences with bounds-- Create a date series covering all order datesSELECT generate_series( (SELECT MIN(order_date) FROM orders)::date, (SELECT MAX(order_date) FROM orders)::date, '1 day'::interval ) AS date_in_range; -- Pattern: Conditional MIN/MAXSELECT MIN(CASE WHEN status = 'completed' THEN order_date END) AS first_completed, MAX(CASE WHEN status = 'completed' THEN order_date END) AS last_completed, MIN(CASE WHEN status = 'pending' THEN order_date END) AS oldest_pendingFROM orders; -- Pattern: Running MIN/MAX (window functions)SELECT date, price, MIN(price) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_min, MAX(price) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_maxFROM stock_prices; -- Pattern: High-water mark (rolling maximum)SELECT date, revenue, MAX(revenue) OVER (ORDER BY date) AS revenue_high_water_mark, revenue - MAX(revenue) OVER (ORDER BY date) AS drawdown_from_peakFROM daily_revenue;Aggregate FIRST/LAST pattern:
While SQL doesn't have built-in FIRST/LAST aggregates in most databases, you can simulate them:
12345678910111213141516171819202122232425262728293031323334
-- Get the first and last value when ordered by another column-- Goal: For each customer, get first purchase amount and last purchase amount -- Using window functionsSELECT DISTINCT customer_id, FIRST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS first_purchase, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_purchaseFROM orders; -- Alternative: Subqueries with MIN/MAX dateSELECT customer_id, (SELECT amount FROM orders o2 WHERE o2.customer_id = o.customer_id ORDER BY order_date LIMIT 1) AS first_purchase, (SELECT amount FROM orders o2 WHERE o2.customer_id = o.customer_id ORDER BY order_date DESC LIMIT 1) AS last_purchaseFROM (SELECT DISTINCT customer_id FROM orders) o; -- PostgreSQL: ARRAY_AGG with subscriptSELECT customer_id, (ARRAY_AGG(amount ORDER BY order_date))[1] AS first_purchase, (ARRAY_AGG(amount ORDER BY order_date DESC))[1] AS last_purchaseFROM ordersGROUP BY customer_id;If you store order numbers as VARCHAR and query MIN(order_number), you'll get '1001' when '999' is actually the largest numeric value. Always use appropriate data types: integers for numbers, dates for dates, etc.
MIN and MAX are fundamental tools for boundary detection and range analysis. Let's consolidate the key concepts:
What's next:
We've now covered all five core aggregate functions: COUNT, SUM, AVG, MIN, and MAX. The final topic in this module addresses a critical cross-cutting concern: how all aggregates handle NULL values. We'll consolidate NULL handling rules and explore advanced patterns for dealing with missing data in aggregation.
You now understand MIN and MAX comprehensively—their NULL handling, data type behaviors, performance characteristics, and practical patterns for range analysis. These tools are essential for data validation, boundary detection, and trend analysis.