What is SQL?
SQL — Structured Query Language — is the standard language for talking to relational databases. Almost every database you have heard of (PostgreSQL, MySQL, SQL Server, Oracle, SQLite) speaks SQL, with small differences in syntax. If you can write SQL against one of them, you can write it against the others with only minor adjustments.
SQL is a declarative language: you describe what data you want, not how to fetch it. The database engine figures out the most efficient way to satisfy your request — using indexes, joins, sorts, and parallelism behind the scenes. This is one reason SQL has stayed dominant for over fifty years: the language describes intent, while the engine evolves underneath it.
In a higher-education setting, SQL is what powers nearly every data question administrators and analysts ask: "How many students are in each department?", "Which programs have the highest GPA?", "Who registered late?" Every example in this guide runs unchanged on QueryU's practice database — an 18-table university dataset modelled on a real student information system. Two of its habits matter from the first query: biographical data (names, birth dates) lives once on ods_person, and each student's program, department and cumulative GPA live on ods_stu_acad_levels. The linked challenges at the end are the auto-graded versions.
The SELECT Statement
Every SQL query starts with SELECT. It is the verb that tells the database "give me these columns from these rows." The shape of a basic SELECT is always the same: list the columns you want, then specify which table they come from.
You can list specific columns by name, or use the wildcard * to pull every column. In production code you should almost always list columns explicitly — SELECT * couples your query to the exact shape of the table, so adding or reordering columns later can silently break downstream consumers.
You can also rename columns in the output with AS, which is helpful when joining tables that share column names or when the underlying name is unfriendly to whoever reads the result. Real system extracts have plenty of those: preferred_email_address is what the registrar's system calls it, contact_email is what your report should say.
-- Specific columns are almost always preferable to SELECT * -- Names live on ods_person: one row per person on file SELECT first_name, last_name, preferred_email_address AS contact_email FROM ods_person;
Filtering with WHERE
The WHERE clause filters which rows appear in your result. It runs after the database identifies the source rows but before the columns are returned, so it has full access to every column in the table — even ones you did not include in the SELECT list.
WHERE supports the comparison operators you would expect (=, <>, <, >, <=, >=) plus a handful of SQL-specific ones: BETWEEN for ranges, IN for sets of values, LIKE for pattern matching, and IS NULL / IS NOT NULL for missing values. You can combine multiple conditions with AND and OR, and group them with parentheses.
A subtle gotcha: in SQL, NULL is not equal to anything — not even another NULL. Writing WHERE column = NULL will never match. Always use IS NULL or IS NOT NULL for null comparisons. The example filters ods_stu_acad_levels, where stpr_status is a one-letter status code (A for active, G for graduated) and stpr_dept is the department code — most coded columns here ship with a readable *_desc twin.
-- Active students in two departments with a strong GPA on file
SELECT students_id, stpr_dept, stpr_gpa
FROM ods_stu_acad_levels
WHERE stpr_status = 'A'
AND stpr_dept IN ('CSCI', 'MATH')
AND stpr_gpa BETWEEN 3.5 AND 4.0
AND stpr_gpa IS NOT NULL;WHERE last_name LIKE 'A%' on ods_person finds surnames starting with A.Sorting with ORDER BY
ORDER BY sorts the rows in your result. By default the sort is ascending — A to Z for text, low to high for numbers, oldest to newest for dates. Use DESC to flip the direction. You can sort on multiple columns; the database sorts by the first column, breaks ties using the second, and so on.
The columns you sort by do not have to appear in your SELECT list. This is a useful trick when you want results in a particular order but do not need to display the sort key. In PostgreSQL you can also control where NULL values land with NULLS FIRST or NULLS LAST, which matters when the column you are sorting by has gaps.
One trap is worth learning on day one, because it produces wrong output without any error. Term codes like 2024SP and 2024FA look sortable, but F comes before S alphabetically, so a text sort puts Fall before Spring in every year. ods_terms carries a real date for exactly this reason: sort on term_start_date, never on the code.
-- Highest cumulative GPA first, ties broken by student id; NULLs at the end
SELECT students_id, stpr_dept, stpr_gpa
FROM ods_stu_acad_levels
ORDER BY stpr_gpa DESC NULLS LAST,
students_id ASC;
-- The academic calendar in chronological order.
-- ORDER BY terms_id would put 2024FA before 2024SP.
SELECT terms_id, term_desc, term_start_date, term_end_date
FROM ods_terms
ORDER BY term_start_date;Grouping with GROUP BY
GROUP BY collapses rows that share a value into one row per group. On its own that is not very interesting; the power comes from pairing it with aggregate functions — COUNT(), SUM(), AVG(), MIN(), MAX() — which compute a single value across all the rows in each group.
A common rule trips up beginners: every column in your SELECT list must either appear in the GROUP BY clause or be wrapped in an aggregate function. If you GROUP BY department and try to also select a student id, the database has no way to pick which student to show — there are many per department. PostgreSQL will reject the query.
To filter groups (rather than rows) use the HAVING clause. WHERE filters rows before grouping; HAVING filters groups after. So WHERE stpr_gpa > 3.0 keeps high-GPA students, but HAVING AVG(stpr_gpa) > 3.0 keeps departments whose average GPA is above 3.0.
-- Active students per department, only departments with 5 or more SELECT stpr_dept, COUNT(*) AS student_count, ROUND(AVG(stpr_gpa), 2) AS avg_gpa FROM ods_stu_acad_levels WHERE stpr_status = 'A' GROUP BY stpr_dept HAVING COUNT(*) >= 5 ORDER BY student_count DESC;
AVG(stpr_gpa) divides by the number of non-null GPAs, not the row count, so a missing GPA does not drag the average down to zero.Limiting Results with LIMIT
LIMIT restricts how many rows the database returns. It is invaluable when exploring an unfamiliar table — SELECT * FROM ods_stu_course_sec LIMIT 10 lets you see what a registration row looks like without scrolling through every enrollment on file.
LIMIT is also how you implement "top N" queries. Combined with ORDER BY, you can pull "the 10 students with the highest GPA" or "the 5 fullest sections." The order of operations matters: the database sorts the entire result set first, then takes the top N.
For pagination, LIMIT pairs with OFFSET to skip rows. LIMIT 10 OFFSET 20 returns rows 21 through 30. Be aware that on very large tables, large OFFSET values are slow — the database still has to scan and discard all the skipped rows. For high-traffic pagination, keyset pagination (where you remember the last seen value) is faster.
-- Top 10 students by cumulative GPA SELECT students_id, stpr_dept, stpr_gpa FROM ods_stu_acad_levels WHERE stpr_gpa IS NOT NULL ORDER BY stpr_gpa DESC, students_id LIMIT 10; -- Page 3 of the same ranking (rows 21-30) SELECT students_id, stpr_dept, stpr_gpa FROM ods_stu_acad_levels WHERE stpr_gpa IS NOT NULL ORDER BY stpr_gpa DESC, students_id LIMIT 10 OFFSET 20;
Practice these on QueryU
Each challenge below exercises one clause from this guide against the same university dataset, and is graded on its result set — any valid query returning the right rows passes. All five are in the free tier.
Department Directory
EasySELECT plus a two-key ORDER BY on ods_departments — the smallest complete query in the bank, and the fastest first pass.
Student Headcount by Class
EasyGROUP BY with COUNT and a column alias on ods_students. The whole aggregation idea in one query.
Academic Calendar
EasyORDER BY on ods_terms — and the first place you learn that the term code is not the sort key, term_start_date is.
Students Born Between 2004 and 2006
EasyA BETWEEN date range in WHERE on ods_person — inclusive on both ends, which is exactly where beginners go wrong.
Top 5 Incoming Students by High School GPA
EasyORDER BY DESC with LIMIT on ods_applications joined to ods_person, an IS NOT NULL guard so missing GPAs do not steer the top-N, and a tie-break so the five are always the same five.
Practice these concepts now
70 hands-on SQL challenges on a real university dataset. The first 31 are free, no installation.