Sub-select takes "ages" - EXCEPT is much faster
in is better for list of constant values. Try using not exists instead. 

Query:

explain analyze select distinct ticket_id from history h
       where not EXISTS (select id from ticket t where t.id = h.ticket_id);


And execution plan:

Unique  (cost=0.58..2294.04 rows=1 width=4) (actual time=23.140..23.140 rows=0 loops=1)
  -&gt;  Merge Anti Join  (cost=0.58..2294.04 rows=1 width=4) (actual time=23.139..23.139 rows=0 loops=1)
        Merge Cond: (h.ticket_id = t.id)
        -&gt;  Index Only Scan using history_ticket_id on history h  (cost=0.29..922.29 rows=30000 width=4) (actual time=0.037..6.848 rows=30000 loops=1)
              Heap Fetches: 30000
        -&gt;  Index Only Scan using ticket_pkey on ticket t  (cost=0.29..1228.29 rows=40000 width=4) (actual time=0.026..6.970 rows=30000 loops=1)
              Heap Fetches: 30000
Total runtime: 23.189 ms


Reason for that I think is that for NOT IN Postgres will need to build distinct list of values from table ticket and then only filter history.
NOT EXISTS does not need to create a list. It can just check if value exists in tickets PK index.  

Usually when you don't get "Anti Join" in such type of queries - something is written bad.