The 30 second answer
For a large subquery, EXISTS can stop at the first match per row, while a badly planned IN might build the whole list first. Modern PostgreSQL often plans both as the same semi-join, so I check EXPLAIN instead of assuming. The bigger trap is NOT IN with a subquery that can return nulls, because it silently returns no rows; NOT EXISTS avoids that.
The docs partly disagree with the original checklist here. In modern PostgreSQL, IN (subquery) and EXISTS are usually planned as the same semi-join, so swapping them often changes nothing. Check with EXPLAIN. The real trap is NOT IN: if the subquery returns any NULL, NOT IN returns NULL (no rows), and it can't become an anti-join. Use NOT EXISTS there.
SELECT * FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);