JSON's numbers survive the wire as characters, and the extraction function's return type — not the value's looks — decides comparison semantics: text ordering, MAX='9', silent coercions replayed one layer up, where it's easier to miss because the source was 'a number'. The casting order of operations: extract raw → branch on null-vs-missing if it matters → cast with the failure mode chosen (::int errors; TRY_CAST returns NULL). Payload drift is the recurring producer.
SELECT json_extract_string(payload, '$.score') AS score FROM prizes ORDER BY json_extract_string(payload, '$.score') ;
SELECT CAST(json_extract_string(payload, '$.score') AS INT) AS score FROM prizes ORDER BY CAST(json_extract_string(payload, '$.score') AS INT);
The leaderboard put '85' before '9' — values extracted from JSON are text until you type them, so they compare alphabetically; casting to INT after extraction orders them 9 then 85.
Extract → distinguish → cast, with TRY/strict chosen; typed accessor functions where the engine offers them.
payload ->> 'user_id'