Primary Keys and Group By’s — A Brief SQL Investigation | by Jeremy Chow | Towards Data Science

Today an acquaintance asked me an interesting SQL question, but not in the typical query sense; his question revolved more around an understanding of the underlying framework of SQL. Here is the context:
The exercise comes from this PostgreSQL exercise page and has the following schema:
The SQL question the website is asking isn’t important, but their posted solution is this:
SELECT  facs.name AS name, facs.initialoutlay/((sum( CASE  WHEN memid = 0   THEN slots * facs.guestcost ELSE slots * membercost END)/3) - facs.monthlymaintenance) AS months  FROM cd.bookings bks  INNER JOIN cd.facilities facs    ON bks.facid = facs.facid  GROUP BY facs.facid ORDER BY name;
My friend tried switching the line GROUP BY facs.facid to GROUP BY facs.name, which broke the query with the message:
column "facs.initialoutlay" must appear in the GROUP BY clause or be used in an aggregate function
The question my friend asked was :
Why does the above query not work with the lines switched, even if both columns are both unique to each row?
If you know the answer, congrats, you should answer my question posted at the end! If you’d like to skip to the answer, scroll to ‘The Answer’ section of this article. Otherwise, let’s go into the thought process of solving this question!
First, let’s check for the obvious — Are those columns really unique, and are there multiple combinations of the columns used in our query (name, facid, initialoutlay, monthlymaintenance)? To check this we look at distinct combinations of those columns in the facilities table.
SELECT DISTINCT facs.name as name, facs.facid, facs.initialoutlay, facs.monthlymaintenance    FROM cd.bookings bks    INNER JOIN cd.facilities facs        ON bks.facid = facs.facidORDER BY facid;Output:╔═════════════════╦═══════╦═══════════════╦════════════════════╗║      name       ║ facid ║ initialoutlay ║ monthlymaintenance ║╠═════════════════╬═══════╬═══════════════╬════════════════════╣║ Tennis Court 1  ║     0 ║         10000 ║                200 ║║ Tennis Court 2  ║     1 ║          8000 ║                200 ║║ Badminton Court ║     2 ║          4000 ║                 50 ║║ Table Tennis    ║     3 ║           320 ║                 10 ║║ Massage Room 1  ║     4 ║          4000 ║               3000 ║║ Massage Room 2  ║     5 ║          4000 ║               3000 ║║ Squash Court    ║     6 ║          5000 ║                 80 ║║ Snooker Table   ║     7 ║           450 ║                 15 ║║ Pool Table      ║     8 ║           400 ║                 15 ║╚═════════════════╩═══════╩═══════════════╩════════════════════╝
Name and facid are both unique to each row and 1 to 1, and each pair only has one initialoutlay and monthlymaintenance value. Intuitively, grouping by either of those two columns should be functionally equivalent to grouping by the other. So why doesn’t grouping by name work for this query?
As you may intuit if you’re familiar with SQL, this is a primary key issue. For those that don’t know, a primary key is the value that uniquely identifies each row of a table, and it can never be ‘NULL’ for a row. But how do we find the designated primary key of a table?
A quick google search gives us the following code from the PostgreSQL wiki. Running this on the website’s query section gives the following:
SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS data_typeFROM   pg_index iJOIN   pg_attribute a ON a.attrelid = i.indrelid                     AND a.attnum = ANY(i.indkey)WHERE  i.indrelid = 'cd.facilities'::regclassAND    i.indisprimary;Output:╔═════════╦═══════════╗║ attname ║ data_type ║╠═════════╬═══════════╣║ facid   ║ integer   ║╚═════════╩═══════════╝
So facid is a primary key of the facilities table! Now we’ve confirmed the probable cause, but what is the reason grouping by the primary key allows you to throw in columns with no aggregate function like we do with facs.initialoutlay and facs.monthlymaintenance?
SELECT  facs.name AS name, facs.initialoutlay/((sum(        /* <=========== */ CASE  WHEN memid = 0   THEN slots * facs.guestcost ELSE slots * membercost END)/3) - facs.monthlymaintenance) AS months /* <=========== */FROM cd.bookings bks  INNER JOIN cd.facilities facs    ON bks.facid = facs.facid  GROUP BY facs.facid ORDER BY name;/* Shouldn't these two columns be inside of an aggregation? */
To answer this question we look at the PostgreSQL help docs, specifically for GROUP BY :
When GROUP BY is present, or any aggregate functions are present, it is not valid for the SELECT list expressions to refer to ungrouped columns except within aggregate functions or when the ungrouped column is functionally dependent on the grouped columns, since there would otherwise be more than one possible value to return for an ungrouped column. A functional dependency exists if the grouped columns (or a subset thereof) are the primary key of the table containing the ungrouped column.
As a Stack Overflow user Tony L. puts it:
Grouping by primary key results in a single record in each group which is logically the same as not grouping at all / grouping by all columns, therefore we can select all other columns.
Essentially this means grouping by the primary key of a table results in no change in rows to that table, therefore if we group by the primary key of a table, we can call on all columns of that table with no aggregate function.
Let’s reiterate: Given that we’re looking at one table, grouping by its primary key is the same as grouping by everything, which is the same as not grouping at all — each of these approaches will result in one group per row. Once you understand that, you understand the crux of this problem.
Because of this, queries like this work:
1. Group by everything:SELECT *FROM cd.facilities fGROUP BY facid, name, membercost, guestcost, initialoutlay, monthlymaintenanceLIMIT 5OUTPUT:
which is functionally identical to
2. Don't group by anythingSELECT * FROM cd.facilities fLIMIT 5and 3. Group by primary key but don't aggregateSELECT * FROM cd.facilities fGROUP BY facidLIMIT 5
These all output the same values! Now we have our solution.
The reason the first query works is simply that facid is the primary key and name is not. Despite them both being unique to each row, the table facilities was created with facid as the primary key, thus it gets special treatment when used in a group by as covered above.
Some alternative solutions to what they posted would be:
1. Group by name then aggregate everything elseSELECT facs.name as name, AVG(facs.initialoutlay)/((sum(case   when memid = 0 then slots * facs.guestcost   else slots * membercost  end)/3) - AVG(facs.monthlymaintenance) as months FROM cd.bookings bks INNER JOIN cd.facilities facs  ON bks.facid = facs.facid GROUP BY facs.nameORDER BY name;Why this works:Because facs.name is unique to each row in the facilities table just as facid was, we group by facs.name then add AVG calls around previously unaggregated facilities columns.2. Group by all facility columns used in the select statementSELECT  facs.name as name, facs.initialoutlay/((sum(case   when memid = 0 then slots * facs.guestcost   else slots * membercost  end)/3) - facs.monthlymaintenance) as months from cd.bookings bks INNER JOIN cd.facilities facs  ON bks.facid = facs.facid GROUP BY facs.name, facs.initialoutlay, facs.guestcost,        facs.monthlymaintenance ORDER BY name;Why this works:This includes all the values used in the SELECT statement in the GROUP BY, which is the normal GROUP BY logic and syntax.
That concludes the main question, but if you are interested in exploring more quirks, I ran into the following issue while solving the posted problem. If any of you know why feel free to ping me or leave a comment!
/* This works (manually lists all columns in the group by)*/SELECT *FROM cd.facilities fGROUP BY facid, name, membercost, guestcost, initialoutlay, monthlymaintenanceLIMIT 5/* This does not (selecting all columns using f.*) */SELECT *FROM cd.facilities fGROUP BY f.*LIMIT 5
Thanks for reading, I hope this article helped you out! Feel free to check out my other tutorials and stay posted for new ones in the future!