The argument for skipping prepare() on an integer is that intval() has already made it safe, and on the day it is written that is true. It stops being true when the parameter becomes a list, or a date, or optional — and the concatenation is still sitting there, now with a string flowing through it.
// safe today, one requirements change from not being
$wpdb->get_results( "SELECT * FROM {$wpdb->prefix}shipments WHERE order_id = " . intval( $_GET['order'] ) );
// the same query, as a habit rather than a judgement call
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}shipments WHERE order_id = %d",
$_GET['order']
) );
// an IN list has no placeholder of its own, so build one
$ids = array_map( 'intval', (array) $order_ids );
$slots = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}shipments WHERE order_id IN ({$slots})",
$ids
) );
There are three placeholders — %s, %d, %f — and %s supplies its own quotes, so adding them by hand produces a doubly quoted value that matches nothing and raises no error. Table and column names cannot be parameterised at all, which is why the prefix is interpolated above and why anything dynamic in that position needs a whitelist instead of escaping. The reason to make this unconditional is reviewability: prepare() is something a reader can grep for and confirm in a second, whereas “this one is an integer and that one came from the database” is an argument that has to be had again on every change to the file.