Intersect and Except in MySQL

Finally

Since version 8.0.31, MySQL supports INTERSECT and EXCEPT. I don't know how I missed that, but finally. It is a part of MySQL now.

To test it I ran MySQL docker container.

Here are the schema and data:

CREATE TABLE `counters` (
  `name` varchar(32) DEFAULT NULL,
  `unread_messages` int DEFAULT NULL,
  `orders` int DEFAULT NULL
) ENGINE=InnoDB
INSERT INTO test.counters (name, unread_messages, orders) VALUES ('John Doe', null, 10);
INSERT INTO test.counters (name, unread_messages, orders) VALUES ('James Bond', 5, 1);
INSERT INTO test.counters (name, unread_messages, orders) VALUES ('Alex', 4, 5);
INSERT INTO test.counters (name, unread_messages, orders) VALUES ('Foo', null, 10);
INSERT INTO test.counters (name, unread_messages, orders) VALUES ('Bar', 10, null);
INSERT INTO test.counters (name, unread_messages, orders) VALUES ('Zip', null, null);
mysql> SELECT * FROM counters;
+------------+-----------------+--------+
| name       | unread_messages | orders |
+------------+-----------------+--------+
| John Doe   |            NULL |     10 |
| James Bond |               5 |      1 |
| Alex       |               4 |      5 |
| Foo        |            NULL |     10 |
| Bar        |              10 |   NULL |
| Zip        |            NULL |   NULL |
+------------+-----------------+--------+
6 rows in set (0.01 sec)

And let's try to intersect :)

mysql> SELECT * FROM counters WHERE unread_messages > 0
    -> INTERSECT
    -> SELECT * FROM counters WHERE orders > 0;
+------------+-----------------+--------+
| name       | unread_messages | orders |
+------------+-----------------+--------+
| James Bond |               5 |      1 |
| Alex       |               4 |      5 |
+------------+-----------------+--------+
2 rows in set (0.00 sec)

Nice! It is exactly what I expected. Then EXCEPT :

mysql> SELECT * FROM counters where unread_messages > 0 EXCEPT SELECT * FROM counters WHERE orders > 0;
+------+-----------------+--------+
| name | unread_messages | orders |
+------+-----------------+--------+
| Bar  |              10 |   NULL |
+------+-----------------+--------+
1 row in set (0.00 sec)

Wow! Finally. Also interesting to see the execution plan

mysql> EXPLAIN SELECT * FROM counters where unread_messages > 0 INTERSECT SELECT * FROM counters WHERE orders > 0\G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: counters
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 6
     filtered: 33.33
        Extra: Using where
*************************** 2. row ***************************
           id: 2
  select_type: INTERSECT
        table: counters
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 6
     filtered: 33.33
        Extra: Using where
*************************** 3. row ***************************
           id: 3
  select_type: INTERSECT RESULT
        table: <intersect1,2>
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: NULL
     filtered: NULL
        Extra: Using temporary

let's add two indexes on unread_messages and orders

CREATE INDEX counters_orders_index
    on counters (orders);

CREATE INDEX counters_unread_messages_index
    on counters (unread_messages);

and let's see the difference

mysql> EXPLAIN SELECT * FROM counters WHERE unread_messages > 0 INTERSECT SELECT * FROM counters WHERE orders > 0 \G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: counters
   partitions: NULL
         type: range
possible_keys: counters_unread_messages_index
          key: counters_unread_messages_index
      key_len: 5
          ref: NULL
         rows: 3
     filtered: 100.00
        Extra: Using index condition
*************************** 2. row ***************************
           id: 2
  select_type: INTERSECT
        table: counters
   partitions: NULL
         type: range
possible_keys: counters_orders_index
          key: counters_orders_index
      key_len: 5
          ref: NULL
         rows: 4
     filtered: 100.00
        Extra: Using index condition
*************************** 3. row ***************************
           id: 3
  select_type: INTERSECT RESULT
        table: <intersect1,2>
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: NULL
     filtered: NULL
        Extra: Using temporary

And for EXCEPT :

mysql> EXPLAIN SELECT * FROM counters where unread_messages > 0 EXCEPT SELECT * FROM counters WHERE orders > 0 \G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: counters
   partitions: NULL
         type: range
possible_keys: counters_unread_messages_index
          key: counters_unread_messages_index
      key_len: 5
          ref: NULL
         rows: 3
     filtered: 100.00
        Extra: Using index condition
*************************** 2. row ***************************
           id: 2
  select_type: EXCEPT
        table: counters
   partitions: NULL
         type: range
possible_keys: counters_orders_index
          key: counters_orders_index
      key_len: 5
          ref: NULL
         rows: 4
     filtered: 100.00
        Extra: Using index condition
*************************** 3. row ***************************
           id: 3
  select_type: EXCEPT RESULT
        table: <except1,2>
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: NULL
     filtered: NULL
        Extra: Using temporary

It is great that MySQL finally supports it. To be honest, I am really happy that I will be able to rewrite my INNER JOIN hacks :)

Did you find this article valuable?

Support Alex Skripov by becoming a sponsor. Any amount is appreciated!