SQL fresher interviews test whether you can explain the basics in your own words: the groups of commands, keys and normal forms, filters, GROUP BY and simple joins. Then comes a small query on a whiteboard, usually on a students or courses table, and a few questions about the database behind your college project. It is written for final-year students, new graduates and anyone coming out of an internship or a bootcamp who is facing a first SQL round. Each question shows what the interviewer is checking, the shape of a good answer and a short answer you can say out loud. Write the queries by hand, then swap the stories for your own.
Search all questions by round, difficulty and level, or save the ones you want to practice.
| SQL | the standard language for asking a relational database for data and changing it. |
|---|---|
| DBMS | the software that stores the data, runs queries and handles many users safely. |
Products: MySQL, PostgreSQL, SQL Server and Oracle all speak SQL, each with its own extras.
“SQL is a language. It's the standard way to ask a relational database for data or to change it, with commands like SELECT, INSERT and UPDATE. A DBMS is the software that actually stores the data, runs my queries, lets many people use it at once and keeps the data safe if something crashes. A relational DBMS keeps data in tables of rows and columns and links them with keys. MySQL, PostgreSQL, SQL Server and Oracle are all relational database products. They all understand SQL, but each adds its own extras, so small things differ. For example, to get only the first few rows you write LIMIT in MySQL and PostgreSQL, TOP in SQL Server, and FETCH FIRST in standard SQL. In my college project I used MySQL, but most of my queries would run on the others with small changes.”
Saying SQL and MySQL are the same thing, or calling SQL a database.
DDL: structure. CREATE, ALTER, DROP, TRUNCATE.
DML: the rows. INSERT, UPDATE, DELETE, and often SELECT.
DCL and TCL: permissions with GRANT and REVOKE; transactions with COMMIT, ROLLBACK and SAVEPOINT.
“DDL is data definition language. These commands shape the structure: CREATE, ALTER, DROP and TRUNCATE. DML is data manipulation language: INSERT, UPDATE and DELETE, which change the rows inside tables. Some people put SELECT in DML too, and some give it its own group called DQL. DCL is data control language, which is GRANT and REVOKE, deciding who's allowed to do what. TCL is transaction control: COMMIT, ROLLBACK and SAVEPOINT, which decide whether a group of changes is kept or undone. One thing worth knowing is that in MySQL and Oracle a DDL command commits on its own, so you can't roll back a DROP there, while PostgreSQL and SQL Server let you run most DDL inside a transaction and roll it back.”
Putting ALTER in DML or UPDATE in DDL, which shows the commands were memorised without understanding them.
Look at the data: clear relationships and fixed fields point to SQL.
Know NoSQL is a family: document, key-value, wide-column and graph stores.
Decide with one real need: tie the choice to something the project had to guarantee.
“I'd start with what the data looks like. If it has clear relationships, like students, courses and enrolments, a SQL database fits well: fixed tables, keys that link them, joins, and transactions so a change either fully happens or doesn't. NoSQL isn't one thing. It's a family that includes document stores, key-value stores, wide-column and graph databases. A document store makes sense when records have different shapes, or when I mostly read and write one whole object at a time, like a user's settings. Many of them are also built to spread across lots of servers. For my project, a hostel booking app, I chose PostgreSQL because bookings had to be consistent. Two students must never get the same bed, and a unique constraint plus a transaction handled that cleanly.”
Saying NoSQL is always faster or more modern, with no link to what the data actually needs.
| ALTER TABLE | DDL, changes the structure such as columns and constraints. |
|---|---|
| UPDATE | DML, changes values in rows that already exist. |
Show both: add a column, then fill it.
“ALTER TABLE is DDL. It changes the shape of the table itself: adding or dropping a column, changing a column's type, or adding a constraint. UPDATE is DML. It leaves the structure alone and changes values in rows that already exist. So if the college decides to store phone numbers, I ALTER the table to add a phone column. Every existing row gets the new column, empty for now. Then I UPDATE rows to fill in the numbers. The easy way I remember it: ALTER talks about the table and its columns, UPDATE talks about rows and values. And an UPDATE with no WHERE changes every row, so I always check the WHERE before running it.”
Saying UPDATE can add a column, or that ALTER changes the values stored in rows.
The change: SET marks = marks + 5 with the right WHERE.
Preview first: run a SELECT with the same WHERE and check the count.
Safety net: run it in a transaction and commit only if the count matches.
“The update itself is short: set marks to marks plus five where section is B. The risky part is the WHERE, because an UPDATE without one changes every row in the table. So first I run a SELECT with exactly the same WHERE and check that the rows and the count look right. Then I run the UPDATE inside a transaction. The database tells me how many rows changed, and if that matches what the SELECT showed, I commit. If not, I roll back and nothing is lost. SQL Server wants BEGIN TRANSACTION, and Oracle starts a transaction on its own, so there I skip BEGIN. I'd also ask whether marks have a maximum, because adding five could push someone over it.”
-- 1. Preview exactly which rows will change
SELECT id, name, marks
FROM students
WHERE section = 'B';
-- 2. Make the change inside a transaction
BEGIN;
UPDATE students
SET marks = marks + 5
WHERE section = 'B';
-- check the reported row count, then:
COMMIT; -- or ROLLBACK if the count looks wrong
Running the UPDATE straight away with no preview, or not knowing that a missing WHERE changes every row.
| CHAR | fixed length, padded with spaces. |
|---|---|
| VARCHAR | variable length, stores only what you give it up to the limit. |
Apply it: country code as CHAR, name and phone number as VARCHAR, with the reason for the phone.
“CHAR is fixed length. If I declare CHAR(10) and store abc, the database pads it with spaces to ten characters. VARCHAR is variable length. VARCHAR(10) stores just what I give it, up to ten characters, plus a small marker for the length. So CHAR suits values that are always the same size, like a two-letter country code, and VARCHAR suits things that vary, like a name. For a phone number I'd use VARCHAR, not a number type. Phone numbers can start with a zero or a plus sign, they can be longer than a normal integer holds, and I never do maths on them. An integer column would quietly drop a leading zero. How trailing spaces are compared differs between databases, so I don't rely on that.”
Storing phone numbers in an INT column, or saying CHAR and VARCHAR are the same.
Spot the link table: many students to many courses needs a table in the middle.
Composite key: the pair of student and course is unique, neither column alone.
Foreign keys: both columns must point at real rows.
“This is a many-to-many relationship. One student takes many courses and one course has many students, so I need a table in the middle where each row is one student in one course. I'd make the primary key the pair student_id and course_id together. That's a composite key: neither column is unique on its own, but the pair is, so the database itself refuses a second row for the same student and course. Both columns are also foreign keys, so nobody can enrol a student or a course that doesn't exist. Grade stays empty until results come out, so I leave it nullable. If the team prefers a single id column on every table, that's fine, but then I'd still add a unique constraint on the pair, or duplicates slip in.”
CREATE TABLE enrolments (
student_id INT NOT NULL,
course_id INT NOT NULL,
enrolled_on DATE NOT NULL,
grade CHAR(2),
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students (id),
FOREIGN KEY (course_id) REFERENCES courses (id)
);
Leaving the pair without a key or unique constraint, so the rule depends on nobody ever making a mistake.
1NF: one value per cell, no lists in a column.
2NF: every non-key column depends on the whole key, not part of it.
3NF: no column depends on another non-key column.
“Say the table has student_id, student_name, course_id, course_name, teacher_name, teacher_phone and grade, and the key is student_id plus course_id. First normal form means one value per cell, so no column holding a list of subjects. Second normal form means every non-key column depends on the whole key. Student_name depends only on student_id, and the course and teacher columns depend only on course_id, so they move out into a students table and a courses table. Only grade needs both, so it stays in enrolments. Third normal form removes columns that depend on another non-key column. Courses now holds teacher_name and teacher_phone, but the phone depends on the teacher, not the course. So I make a teachers table and keep just teacher_id in courses. Now a teacher's new phone number is one update, not fifty.”
Reciting the definitions with no example, or splitting tables without being able to say which dependency caused the split.
Show the pain: the queries that become hard or wrong.
Offer the fix: a separate table with one row per student and course.
Make it easy: offer to do the work, and do it while the data is small.
“I'd say it's great that it works, but it'll hurt us soon, and I'd show why with real queries. Finding every student in course 104 means matching text, and a careless pattern also matches 1045. Counting students per course, removing one course from a student, or joining to the courses table for names all turn into messy string work. The database also can't check that 107 is a real course, because it's just text, not a foreign key. The fix is an enrolments table with one row per student and course, which is exactly what first normal form asks for. I'd offer to write the change and update the few queries that use the column, so it doesn't feel like I'm handing them extra work, and I'd suggest we do it now while the data is small.”
Calling the teammate's work wrong without showing a single query that breaks, or letting it slide because it works today.
Percent sign: any number of characters, including none.
Underscore: exactly one character.
Watch out for: case rules that differ by database, and slow leading wildcards.
“LIKE matches patterns, and it has two wildcards. The percent sign stands for any number of characters, including none. The underscore stands for exactly one character. So A followed by a percent sign means the name starts with A and anything can follow. For the third letter, I put two underscores for the first two letters, then r, then a percent sign for whatever comes after. Whether a lowercase a matches a capital A depends on the database. MySQL is usually case-insensitive by default, PostgreSQL isn't, so there I'd use ILIKE or apply LOWER to the column. And a pattern that starts with a wildcard can't use a normal index well, so on a big table, searching for names that end in something is slow.”
SELECT name FROM students WHERE name LIKE 'A%';
SELECT name FROM students WHERE name LIKE '__r%';
Mixing up the two wildcards, or using an asterisk as in file searches.
Read it like the database: AND is grouped first, so the marks filter applies only to IT.
Fix with brackets: group the OR, then apply the marks condition.
Cleaner still: IN with a list of departments.
“AND is evaluated before OR, the same way multiplication comes before addition. So the database reads it as: dept is CS, or dept is IT and marks are above 80. Every CS student comes back whatever their marks, and only the IT students get the marks filter. The fix is brackets around the two departments, so the OR is settled first and the marks condition then applies to both. Even cleaner is IN with a list, which says exactly what I mean in one step. My habit now is to add brackets whenever I mix AND and OR, even when I'm sure of the order, because the next person reading the query shouldn't have to work it out.”
-- AND is grouped first, so the original means:
-- dept = 'CS' OR (dept = 'IT' AND marks > 80)
SELECT name, dept, marks
FROM students
WHERE (dept = 'CS' OR dept = 'IT')
AND marks > 80;
-- same rows, easier to read
SELECT name, dept, marks
FROM students
WHERE dept IN ('CS', 'IT')
AND marks > 80;
Saying the conditions are checked left to right, or adding DISTINCT to hide the extra rows.
Star: COUNT(*) counts every row.
One column: COUNT(email) counts rows where email is not NULL.
Distinct: COUNT(DISTINCT city) counts different non-NULL values.
“COUNT(*) counts rows, all of them, whatever is in the columns. COUNT(email) counts only the rows where email is not NULL, so if twenty students haven't given an email, it comes back twenty lower than COUNT(*). COUNT(DISTINCT city) counts how many different non-NULL cities there are, so a hundred students from four cities gives four. I use the gap between the first two as a quick data check: COUNT(*) minus COUNT(email) tells me how many emails are missing. People sometimes say COUNT(1) is faster than COUNT(*), but in the databases I've used they're handled the same way, so I just write COUNT(*).”
SELECT COUNT(*) AS total_students,
COUNT(email) AS with_email,
COUNT(*) - COUNT(email) AS missing_email,
COUNT(DISTINCT city) AS cities
FROM students;
Saying COUNT(column) and COUNT(*) always give the same number.
Group and count: GROUP BY dept with COUNT(*).
Sort: ORDER BY the count, descending.
The rule: every selected column is either grouped or inside an aggregate.
“GROUP BY dept squashes all the rows for one department into a single row, and COUNT(*) counts how many rows went into each group. ORDER BY with DESC puts the biggest department first, and I can sort by the alias because ORDER BY runs after SELECT. If I add name to the SELECT, the database doesn't know which name to show, because one department row stands for many students. So the rule is that every column in the SELECT must be either in the GROUP BY or inside an aggregate like COUNT, MAX or MIN. Most databases throw an error. Older MySQL settings used to let it run and pick any name, which is worse, because the answer looks fine but is random.”
SELECT dept, COUNT(*) AS student_count
FROM students
GROUP BY dept
ORDER BY student_count DESC;
Adding name to the GROUP BY to make the error go away, which changes the question being answered.
Why it fails: MAX gives one row, with no rule for which name goes with it.
Fix: a subquery finds the top marks, the outer query finds who has them.
Ties: say what your version does when two students share the top.
“MAX is an aggregate, so it collapses the whole table into one value. Asking for name next to it is the same problem as grouping without name: the output has one row and there's no rule for which name to put in it, so most databases give an error. The clean fix is a subquery. The inner query finds the highest marks, and the outer query returns every student who has exactly that score. I like this version because if two students tie for the top, both come back, which is usually what you want. Sorting by marks descending and taking one row with LIMIT also works, but it silently drops the second student in a tie.”
SELECT name, marks
FROM students
WHERE marks = (SELECT MAX(marks) FROM students);
Using ORDER BY with LIMIT 1 without mentioning that it hides a tie.
Start from departments: LEFT JOIN students so empty departments stay.
The trap: an unmatched department is one row of NULLs, and COUNT(*) counts it.
The fix: COUNT a student column, which skips NULLs.
“An inner join would drop the empty departments, because they have no matching student rows. So I start from departments and LEFT JOIN students. An empty department still appears, once, with NULL in every student column. Now the trap: if I write COUNT(*), that one row of NULLs counts as a row, so an empty department shows one student. COUNT(s.id) counts only non-NULL ids, so it correctly gives zero. I group by the department id as well as the name, in case two departments share a name. I'd test it by adding a department with no students and checking it shows zero.”
SELECT d.name, COUNT(s.id) AS student_count
FROM departments d
LEFT JOIN students s ON s.dept_id = d.id
GROUP BY d.id, d.name
ORDER BY student_count DESC;
Using an inner join and losing the empty departments, or using COUNT(*) and reporting one student where there are none.
Start from the link table: each enrolment row is one student in one course.
One ON per join: student id to students, course id to courses.
Say what's left out: inner joins skip students with no enrolments.
“The enrolments table is the link. Each row says one student took one course and got a grade. So I start from enrolments, join students on the student id to get the name, then join courses on the course id to get the title. Each join needs its own ON condition. If I match one on the wrong column, the rows pair up wrongly without any error, so I read each ON line carefully. I use short aliases so the query stays readable and it's clear which table each column comes from. These are inner joins, so a student with no enrolments won't appear. If the task was to list every student, even those with no courses, I'd start from students and LEFT JOIN both enrolments and courses.”
SELECT s.name, c.title, e.grade
FROM enrolments e
JOIN students s ON s.id = e.student_id
JOIN courses c ON c.id = e.course_id
ORDER BY s.name, c.title;
Joining students straight to courses with no link table, or leaving out a join condition.
What it does: every row of one table paired with every row of the other.
Real use: all combinations, such as sizes with colours.
The accident: a comma join with the linking condition missing.
“A CROSS JOIN pairs every row of one table with every row of the other, with no join condition. If one table has three rows and the other has four, I get twelve. That's called a Cartesian product. It's useful when I really want every combination, like every T-shirt size with every colour to create product variants, or every student with every subject to build an empty marks sheet for teachers to fill in. The accident happens with the old comma style, FROM students, courses, when someone forgets the WHERE condition that links them. A thousand students and fifty courses suddenly gives fifty thousand rows. That's one reason I write explicit JOIN with ON, because a missing condition is much easier to spot.”
-- every size with every colour: 3 sizes x 4 colours = 12 rows
SELECT s.size_name, c.colour_name
FROM sizes s
CROSS JOIN colours c;
Saying a CROSS JOIN is the same as a FULL OUTER JOIN.
Why it fails: equals compares one value with one value.
Fix: IN, EXISTS or a join when the subquery can return many rows.
Don't hide it: LIMIT 1 silences the error and drops real rows.
“The equals sign compares one value with one value. It worked while block A had one department, but once a second department moved into block A, the subquery returned two ids, and the database can't compare dept_id with a list. So the fix is IN, which checks whether dept_id matches any value in the list. A subquery that returns exactly one value is called a scalar subquery, and that's the kind you can use with equals, like comparing marks with MAX(marks). One that can return many rows needs IN, EXISTS or a join. What I wouldn't do is add LIMIT 1 to silence the error, because that picks one department and quietly ignores the rest.”
SELECT name
FROM students
WHERE dept_id IN (SELECT id FROM departments WHERE block = 'A');
Adding LIMIT 1 or MAX to the subquery just to make the error disappear.
Turn every into a count: courses per student equals total courses.
Group, then filter: GROUP BY student, HAVING on the count.
Say the assumptions: DISTINCT for repeats, foreign keys for valid course ids.
“I turn every course into a count. For each student, I count how many different courses they're enrolled in, and keep only the students whose count equals the total number of courses. The join brings in their enrolments, GROUP BY gives one row per student, and HAVING filters on the count, because WHERE can't see aggregates. DISTINCT inside the count matters if the same course can appear twice for a student, say after a repeat. One assumption I'd say out loud: this is right only if enrolments point at courses that exist, which a foreign key guarantees. There's also a double NOT EXISTS version, find students for whom no course exists that they're not enrolled in. It's harder to read, but it's the classic answer to this kind of question, which is called relational division.”
SELECT s.id, s.name
FROM students s
JOIN enrolments e ON e.student_id = s.id
GROUP BY s.id, s.name
HAVING COUNT(DISTINCT e.course_id) = (SELECT COUNT(*) FROM courses);
Writing WHERE course_id = 1 AND course_id = 2, which can never be true for a single row.
Order the WHENs: highest first, because CASE stops at the first match.
ELSE and NULL: NULL marks fail every comparison and fall into ELSE.
Count per label: wrap the CASE and group by it.
“CASE checks its WHEN conditions from top to bottom and stops at the first one that's true. That's why I write them from highest to lowest. A mark of 75 fails the first check, passes the check for 60 and above, and becomes B, so I don't need to write between 60 and 79. ELSE catches everything else. One subtle point: a student with NULL marks fails every comparison and lands in ELSE, so they'd show as Fail. If that's wrong, I'd add a first line that labels NULL as Absent. To count per label, I put the CASE in a subquery and group by the label. Some databases let you group by the alias directly, but the subquery works everywhere.”
SELECT grade_label, COUNT(*) AS students
FROM (
SELECT CASE
WHEN marks >= 80 THEN 'A'
WHEN marks >= 60 THEN 'B'
WHEN marks >= 40 THEN 'C'
ELSE 'Fail'
END AS grade_label
FROM students
) labelled
GROUP BY grade_label
ORDER BY grade_label;
Putting the conditions in the wrong order so every passing student gets the lowest label, or forgetting what happens to NULL.
Why not two updates: the second one flips everything back.
One statement: CASE works out each row's new value from its old one.
Protect the rest: ELSE keeps NULLs and odd values unchanged.
“If I run two separate updates, first Y to N and then N to Y, the second one flips everything back and I end up with all Y. The trick is to do both in one statement with CASE. The database works out each row's new value from that row's old value, so every Y becomes N and every N becomes Y in the same step. I add ELSE is_active so any other value, like a NULL or a typo, stays as it was. Without that ELSE, those rows would be set to NULL, which is a quiet way to damage data. There's no WHERE because the task really is every row, but I'd still check the counts of Y and N before and after.”
UPDATE students
SET is_active = CASE is_active
WHEN 'Y' THEN 'N'
WHEN 'N' THEN 'Y'
ELSE is_active
END;
Writing two separate UPDATE statements, or leaving out ELSE and wiping unexpected values to NULL.
Build it: CONCAT with a space, then UPPER.
Filter it: LENGTH in WHERE, repeating the expression because WHERE can't see the alias.
Know the differences: function names and NULL handling vary by database.
“CONCAT joins the two names with a space between them, UPPER turns the result into capitals, and LENGTH counts the characters. I repeat the expression in WHERE rather than using the alias, because WHERE runs before SELECT and can't see full_name yet. These functions vary a little. SQL Server calls it LEN, MySQL's LENGTH counts bytes, so CHAR_LENGTH is safer for accented names, and Oracle's CONCAT takes only two values, so there I'd use the double pipe operator. NULLs matter too. In MySQL, CONCAT returns NULL if any part is NULL, while PostgreSQL's CONCAT simply skips NULL parts. So if last_name can be missing, I'd wrap it in COALESCE to be safe.”
SELECT UPPER(CONCAT(first_name, ' ', last_name)) AS full_name
FROM students
WHERE LENGTH(CONCAT(first_name, ' ', last_name)) > 12;
Using the alias in WHERE and being surprised by the error, or never thinking about a NULL last name.
The project in one line: what it did and who used it.
The tables: the main ones and how they linked, with one decision you made on purpose.
Looking back: one concrete thing you'd change and why.
“In my final-year project we built a canteen pre-order app. The database was PostgreSQL, with five tables: users, menu_items, orders, order_items and payments. Orders held who ordered and when, and order_items linked each order to menu items with a quantity, since one order has many items. I copied the price into order_items at the time of ordering, because menu prices changed during the semester and old bills had to stay correct. What I'd do differently: we stored the order status as free text, and ended up with Ready, ready and READY in the data. Now I'd use a CHECK constraint or a small lookup table so only valid values get in. I'd also add indexes on the foreign key columns earlier, because the orders screen slowed down once we loaded test data.”
Describing the app's screens and never the tables, or claiming the design had nothing you'd change.
How you noticed: a number that couldn't be right, or a hand check.
How you narrowed it: run the parts on their own until one is wrong.
What changed after: the habit you kept.
“In our DBMS lab we used PostgreSQL, and one task was to show the pass rate for each subject. My query divided the number of students who passed by the total, and every subject came out as zero. I knew that was impossible, since most people passed. I ran the two counts on their own and both looked right, so the problem had to be the division. That's when I learned that dividing one integer by another in PostgreSQL gives an integer, so 45 divided by 60 is cut down to zero. I fixed it by multiplying by 100.0 first, which makes the whole calculation decimal, then rounding to one place. MySQL would have given a decimal here, so now I test the maths on a tiny case I can check by hand.”
SELECT subject,
ROUND(100.0 * SUM(CASE WHEN marks >= 40 THEN 1 ELSE 0 END) / COUNT(*), 1) AS pass_rate
FROM results
GROUP BY subject;
Saying none of your queries has ever been wrong, or fixing it by trial and error with no idea why it worked.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your live interview audio and screen are never stored. Your resume and notes are saved to your account so the app fills them in on any computer. It stays out of screen share on every plan, including Free; only you can see it.