TechEarl

Why ACF Relationship Fields Lose Their Selected Order

ACF Relationship fields preserve order in the editor and in the stored array, but lose it when you pass the IDs to WP_Query. The fix: post__in plus orderby=post__in, every time. Plus when ACF returns objects vs IDs.

Ishan Karunaratne⏱️ 5 min readUpdated
Share thisCopied
ACF Relationship fields keep selected order in the field but lose it in WP_Query. The post__in + orderby=post__in fix, with examples for every pattern.

ACF Relationship fields preserve the editor's drag-and-drop order in the stored array. The order survives get_field(). It does NOT survive passing the IDs to WP_Query, because the default WP_Query order is post_date DESC and post__in does not override it. The fix is one extra orderby argument on every query that consumes Relationship field output, and it is the single most common gotcha with this field type.

Jump to:

What ACF actually returns

The ACF Relationship field has a "Return Format" setting with two options:

  • Post Object (default): returns an array of WP_Post objects in the editor's order.
  • Post ID: returns an array of integer IDs in the editor's order.

Both preserve order. The stored database value is an array of integer IDs, and get_field either returns them directly (ID format) or hydrates each one into a WP_Post (Object format), both in array order.

php
$related = get_field( 'related_posts', $post_id );
// With Post ID format:    [42, 7, 99, 13]
// With Post Object format: [WP_Post(42), WP_Post(7), WP_Post(99), WP_Post(13)]

That order is what the editor dragged in the field. ACF respects it.

Why WP_Query loses the order

The trouble starts when you take those IDs and pass them to WP_Query:

php
$ids = get_field( 'related_posts' );
$query = new WP_Query( [
    'post_type' => 'post',
    'post__in' => $ids,
] );
// Query returns posts in DEFAULT order (post_date DESC), not in $ids order

post__in filters which posts the query returns; it does not order them. The default orderby for WP_Query is post_date DESC. So the editor's order is silently lost as soon as the IDs enter the query.

This is by design (consistent with post__in's SQL semantics) but it is the cause of approximately 80% of "my Relationship field is showing the wrong order" tickets I have ever debugged.

The fix: orderby=post__in

The fix is one argument:

php
$ids = get_field( 'related_posts' );
$query = new WP_Query( [
    'post_type' => 'post',
    'post__in' => $ids,
    'orderby' => 'post__in', // <-- this
    'posts_per_page' => -1,
] );

orderby => 'post__in' tells WP_Query to return posts in the same order as the post__in array. ACF's stored order is preserved end-to-end. This single argument is the answer to almost every "Relationship field lost order" question.

Watch the gotcha: if you ALSO pass other ordering arguments ('orderby' => ['menu_order', 'post__in'] or similar combined forms), the post__in ordering may interact in unexpected ways. Default to the simple form unless you have a specific reason.

Pattern 1: looping over the array directly

When the rendered output is simple (a card per related post), the cleanest pattern skips WP_Query entirely and loops over the array ACF returned:

php
$related = get_field( 'related_posts' ); // Post Object format
if ( $related ) {
    foreach ( $related as $post ) {
        setup_postdata( $post );
        ?>
        <article class="card">
            <h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
            <p><?php the_excerpt(); ?></p>
        </article>
        <?php
    }
    wp_reset_postdata();
}

This always preserves order because you are iterating the array directly. No WP_Query, no orderby concern.

The setup_postdata call lets you use the standard template tags (the_permalink, the_title, the_excerpt) as if you were inside the main loop. wp_reset_postdata restores the original $post global when you are done.

Pattern 2: passing IDs to WP_Query

When you need WP_Query's filtering features (tax_query, meta_query, status filtering), pass the IDs in and use orderby=post__in:

php
$ids = get_field( 'related_posts' ); // can be ID or Object format; this normalizes
if ( is_array( $ids ) && ! empty( $ids ) ) {
    $ids = array_map( fn( $p ) => is_object( $p ) ? $p->ID : (int) $p, $ids );
}

$query = new WP_Query( [
    'post_type' => 'post',
    'post__in' => $ids,
    'orderby' => 'post__in',
    'posts_per_page' => -1,
    'post_status' => 'publish', // useful if Relationship was set when posts were drafts
] );

while ( $query->have_posts() ) {
    $query->the_post();
    // ...
}
wp_reset_postdata();

This pattern is right when you want to filter the related posts (e.g., "only show published ones," "only ones in a specific category"). The order from the field is preserved.

Pattern 3: using setup_postdata in the_loop

When the related posts are displayed inside the main loop of a different post, the setup/reset pattern matters because the existing $post global needs to be restored:

php
the_post(); // main loop

// Inside the loop, render related posts
$related = get_field( 'related_posts' );
if ( $related ) {
    global $post; // save the outer post
    $outer_post = $post;

    foreach ( $related as $post ) {
        setup_postdata( $post );
        get_template_part( 'template-parts/related-card' );
    }

    $post = $outer_post;
    setup_postdata( $post ); // restore the outer post for the rest of the main loop
}

The manual $post save/restore matters because setup_postdata mutates the global. Without restoring it, the rest of your main-loop template code reads the LAST related post instead of the actual current post.

wp_reset_postdata reads from $wp_query->post to restore the global, which is fine when the related rendering is OUTSIDE the main loop, but inside the main loop it resets to "the current main-loop post" which may not be what you want if you have already advanced past it.

When ACF returns objects vs IDs

The Return Format setting controls what get_field gives you back. The data stored in the database is always an array of integer IDs; the format just controls hydration on read.

  • Post Object format: ACF runs get_post( $id ) for each ID and returns the array of WP_Post objects. Simpler to use in templates, slightly more meta read overhead per page.
  • Post ID format: returns the raw IDs. You hydrate yourself if you need the posts. Cheaper when you only want the IDs (for a WP_Query) or when you do not need the post objects at all.

For most agency templates, Post Object is the right default; the simplicity wins. For high-scale code paths (thousands of related-posts lookups), Post ID is faster.

To change the Return Format on an existing field: switch the setting in the field group editor. The stored data (the integer ID array) does not change. Templates need to be updated to handle the new shape. See How to Update ACF Fields Programmatically for the related write-side patterns.

For deeper coverage of building related-post systems on Relationship fields (including the indexing and caching patterns at scale), see Simple Related Posts System Using ACF Relationship Fields.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressACFRelationship FieldWP_Query

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Filter ACF Relationship Fields by Post Type

ACF Relationship fields let editors pick from any post type by default. The Post Type filter in the field group editor restricts the choice list. For dynamic filtering (taxonomy, status, ACF field), the acf/fields/relationship/query filter is the right tool.

Why ACF True/False Fields Sometimes Behave Unexpectedly

ACF True/False fields store 1 or 0 in the database but appear as booleans in PHP. Loose-comparison bugs, empty-row defaults, conditional logic visibility, and meta_query gotchas are the four causes of unexpected behavior. Here is the fix for each.