Is EXISTS faster than IN? What does the optimizer actually do with each one?
For the common case — a subquery that does not reference the outer row — the two produce identical plans and identical runtimes. The optimizer rewrites an IN subquery into a semi-join, and a semi-join is exactly what EXISTS expresses. MySQL calls it a semijoin optimisation and enables it by default; PostgreSQL rewrites IN into a hashed semi-join when it can, and uses a nested-loop semi-join with an index otherwise; SQLite transforms IN into a membership loop over a subquery.
The rewrite has an important consequence. When the optimizer converts your IN into a semi-join, it stops deduplicating the subquery's rows — because a semi-join only needs to know whether a match exists, not how many. This is why IN on a large subquery can be fast: it stops at the first match per outer row rather than materialising and deduplicating the whole set.
Where the two genuinely differ is when the subquery is correlated. A correlated EXISTS has to run per outer row, and so does a correlated IN, but the EXISTS version can stop at the first matching row inside the inner query while the IN version has to evaluate the inner expression for every candidate. On large tables that difference is real.
There is also the NULL asymmetry to remember. NOT IN with a subquery that can return NULL returns no rows at all, because every NOT UNKNOWN is still UNKNOWN. NOT EXISTS has no such failure mode. So the practical recommendation is to use EXISTS when the subquery might contain NULLs, and IN when you are matching a known, small, non-null list — for which IN is clearer.