A Python 2 to 3 migration is not a syntax problem. It's a data problem wearing a syntax costume.
The automated tools rewrite prints and imports in an afternoon. What they cannot touch is every place the old code treated bytes and text as interchangeable: file reads, socket payloads, database drivers, anything pickled or cached back when nobody had to decide.
Under 2 that ambiguity got resolved at runtime, usually by accident. Under 3 it becomes a boundary you have to declare explicitly, and the codebase never recorded where those boundaries were.
The failures don't surface in tests. They surface on the one row with an unusual encoding, the legacy record written by a service that no longer exists, the comparison that used to work because 2 would order anything against anything.
How I approach it: map the I/O edges first and pin encoding at each one, then run both versions against production-shaped data and diff the outputs instead of trusting a green suite. Migrate boundary by boundary, not module by module.
Nothing in the language will point at those edges for you. Only real data will.
If you've been through one: where did yours break first — the ORM layer, the caches, or file and CSV handling?