10. August 2026

ACF-Felder per WP-CLI aus einer JSON-Datei importieren

Beim Relaunch liegen die Inhalte in der alten Installation, die neue hat nur die leere Feldstruktur. Hier steht das WP-CLI-Skript, das Custom Fields aus einer JSON-Datei über den Slug in die ACF-Felder schreibt, samt Trockenlauf und der Stelle, an der update_field() still nichts tut.

Beim Relaunch liegen die alten Inhalte in der alten Installation, und die neue hat nur die leere Feldstruktur. In meinem Fall: 31 Einträge eines Custom Post Type, je sechs Custom Fields. Abtippen ist keine Option. Ein Datenbank-Dump hilft auch nicht, weil die Post-IDs und die Feld-Keys auf beiden Seiten verschieden sind.

Ich habe die Werte deshalb als JSON abgelegt und per WP-CLI in die ACF-Felder geschrieben. Zugeordnet wird über den Slug, nicht über die ID.

Getestet mit

  • WordPress 7.0.2
  • ACF 6.8 (Free; mit Pro identisch)
  • WP-CLI 2.12.0
  • PHP 8.3 auf Debian 12

Das Verhalten von update_field(), um das es weiter unten geht, ist seit ACF 5 unverändert.

Schritt 1: Die Feld-Keys der Zielinstallation auslesen

Du brauchst die Feld-Keys, nicht die Feldnamen. Warum, steht im Abschnitt darunter. Diese Zeile listet jede Feldgruppe mit ihren Feldern:

wp eval 'foreach ( acf_get_field_groups() as $group ) { echo $group["key"] . "  " . $group["title"] . "n"; foreach ( acf_get_fields( $group["key"] ) as $field ) { echo "    " . $field["key"] . "  " . $field["name"] . "n"; } }'

Ausgabe bei mir:

group_cd44939946b37  Projektinfos
    field_6a393ba1d3ab7  projektname
    field_6a393c74d3ab8  kurzbeschreibung
    field_6a393ca5d3ab9  badges
    field_6a393ce5d3abb  herausforderung
    field_6a393cf0d3abc  losung
    field_6a393cfdd3abd  ergebnis

Schritt 2: Die Daten als JSON ablegen

Ein Objekt pro Eintrag. Pflicht ist der slug, alles andere sind die Quellwerte. Die Datei heißt felder-daten.json und liegt neben dem Skript:

{
  "eintraege": [
    {
      "slug": "BEISPIEL-SLUG",
      "projektname": "Beispielprojekt",
      "kurzbeschreibung": "Ein Satz Teasertext.",
      "herausforderung": "<p>Langtext als HTML.</p>",
      "losung": "<p>Langtext als HTML.</p>",
      "ergebnis": "<p>Langtext als HTML.</p>"
    }
  ]
}

Woher die Werte kommen, ist für den Import egal. Bei mir standen sie in einem anderen Feld-Plugin, das seine Werte nicht über die REST-API ausliefert. Ich habe sie deshalb aus dem gerenderten Frontend der alten Seite gezogen. Unschön, aber es war der kürzeste Weg zu vollständigen Daten.

Schritt 3: Das Importskript

Speichern als import-felder.php. Angepasst wird nur der Block $FIELD_MAP und $POST_TYPE. Links steht der Schlüssel aus der JSON-Datei, rechts der ACF-Feld-Key aus Schritt 1:

<?php
if ( ! defined( 'ABSPATH' ) ) {
    fwrite( STDERR, "Bitte über WP-CLI ausführen: wp eval-file import-felder.phpn" );
    exit( 1 );
}

$FIELD_MAP = array(
    'projektname'      => 'field_6a393ba1d3ab7',
    'kurzbeschreibung' => 'field_6a393c74d3ab8',
    'herausforderung'  => 'field_6a393ce5d3abb',
    'losung'           => 'field_6a393cf0d3abc',
    'ergebnis'         => 'field_6a393cfdd3abd',
);

$POST_TYPE = 'DEIN-CPT';

$tokens = array();
if ( isset( $args ) && is_array( $args ) ) {
    $tokens = $args;
}
$tokens    = array_map( function ( $t ) { return ltrim( (string) $t, '-' ); }, $tokens );
$DRY_RUN   = in_array( 'dry-run', $tokens, true );
$OVERWRITE = in_array( 'overwrite', $tokens, true );

if ( ! function_exists( 'update_field' ) ) {
    WP_CLI::error( 'update_field() nicht gefunden. Ist ACF aktiv?' );
}

$json_path = __DIR__ . '/felder-daten.json';
if ( ! file_exists( $json_path ) ) {
    WP_CLI::error( "felder-daten.json nicht gefunden: $json_path" );
}

$data = json_decode( file_get_contents( $json_path ), true );
if ( empty( $data['eintraege'] ) ) {
    WP_CLI::error( 'felder-daten.json enthält keine Einträge.' );
}

WP_CLI::log( 'Modus: ' . ( $DRY_RUN ? 'DRY-RUN' : ( $OVERWRITE ? 'ECHT + ÜBERSCHREIBEN' : 'ECHT, nur leere Felder' ) ) );

$gesetzt = 0;
$fehlend = array();

foreach ( $data['eintraege'] as $eintrag ) {
    $slug = $eintrag['slug'];
    $post = get_page_by_path( $slug, OBJECT, $POST_TYPE );

    if ( ! $post ) {
        WP_CLI::warning( "Kein Eintrag mit Slug '$slug' – übersprungen." );
        $fehlend[] = $slug;
        continue;
    }

    $post_id = (int) $post->ID;
    WP_CLI::log( "» [$post_id] $slug" );

    foreach ( $FIELD_MAP as $quelle => $feld_key ) {
        $wert = isset( $eintrag[ $quelle ] ) ? trim( (string) $eintrag[ $quelle ] ) : '';
        if ( $wert === '' ) {
            continue;
        }

        $aktuell = (string) get_field( $feld_key, $post_id );
        if ( ! $OVERWRITE && trim( $aktuell ) !== '' ) {
            WP_CLI::log( "    · $feld_key bereits befüllt" );
            continue;
        }

        if ( $DRY_RUN ) {
            WP_CLI::log( "    + $feld_key  ←  " . mb_substr( $wert, 0, 60 ) );
            $gesetzt++;
            continue;
        }

        update_field( $feld_key, $wert, $post_id );

        $referenz = get_post_meta( $post_id, '_' . $feld_key, true );
        if ( get_post_meta( $post_id, $feld_key, true ) !== '' || $referenz !== '' ) {
            WP_CLI::log( "    + $feld_key gesetzt" );
            $gesetzt++;
        } else {
            WP_CLI::warning( "    ! $feld_key steht nicht in der Datenbank" );
        }
    }
}

WP_CLI::log( sprintf( 'Fertig. Felder gesetzt: %d · Slugs nicht gefunden: %d', $gesetzt, count( $fehlend ) ) );
if ( $fehlend ) {
    WP_CLI::log( 'Nicht gefunden: ' . implode( ', ', $fehlend ) );
}

Die Prüfung nach dem Schreiben liest absichtlich die Metafelder statt den Rückgabewert von update_field() auszuwerten. Auch dazu unten mehr.

Schritt 4: Erst trocken, dann echt

Alle drei Befehle laufen im WordPress-Root der Zielinstallation. Vorher ein Backup ziehen.

wp eval-file import-felder.php dry-run
wp eval-file import-felder.php
wp eval-file import-felder.php overwrite

Die Wörter hinter dem Dateinamen sind positionale Argumente. WP-CLI legt sie in $args ab, das Skript liest sie von dort. Ohne Argument füllt es nur leere Felder, mit overwrite überschreibt es auch belegte.

Das Skript meldete Erfolg, die Felder blieben leer

Die erste Fassung der $FIELD_MAP hatte rechts die Feldnamen stehen, nicht die Keys. Also 'herausforderung' => 'herausforderung'. Der Trockenlauf sah sauber aus, der Echtlauf lief ohne Warnung durch, und im Frontend war jedes Feld leer.

Gesehen habe ich es hier:

wp post meta list POST-ID --format=table

In der Tabelle standen die Werte. Was fehlte, war zu jedem Feld die zweite Zeile mit dem Unterstrich davor. ACF legt pro Feld nämlich zwei Metafelder an: herausforderung mit dem Wert und _herausforderung mit dem Feld-Key. Über diese Referenz findet ACF die Felddefinition. Ohne sie weiß ACF nichts vom Feld, und get_field() gibt nichts zurück, obwohl der Wert in der Datenbank steht.

Der Feldname reicht update_field() nur, wenn das Feld für diesen Post schon einen Wert hat — dann existiert die Referenz bereits und ACF kann den Namen auflösen. Bei einem Import ist das nie der Fall. Mit dem Feld-Key legt ACF beide Zeilen an. Seitdem steht in meinen Import-Maps rechts immer field_.

Danach lief ich in die zweite Stelle. Nach dem Umstellen auf Keys gab update_field() bei einzelnen Feldern weiterhin false zurück, und der Wert stand trotzdem korrekt in der Datenbank. update_field() reicht am Ende an update_post_meta() durch, und das liefert false, wenn der neue Wert mit dem alten identisch ist. Bei einem zweiten Lauf über dieselben Daten meldet dir das Skript also Fehler, wo keine sind. Deshalb prüft die Fassung oben die Metafelder statt den Rückgabewert.

Kontrolle

Erst der Wert, dann die Referenz. Beides muss antworten:

wp post meta get POST-ID herausforderung
wp post meta get POST-ID _herausforderung

Der zweite Befehl gibt den Feld-Key aus, zum Beispiel:

field_6a393ce5d3abb

Kommt hier eine leere Zeile, hat der Import die Referenz nicht angelegt und das Frontend bleibt leer. Über alle Einträge auf einmal:

for id in $(wp post list --post_type=DEIN-CPT --format=ids); do
  printf '%s ' "$id"
  wp post meta get "$id" _herausforderung 2>/dev/null || echo 'FEHLT'
done

Erwartbar ist eine Zeile pro Eintrag mit ID und Feld-Key. Jedes FEHLT ist ein Eintrag, den du dir ansehen musst.

Häufige Fehler

Ich habe kein WP-CLI auf dem Zielserver. Dann pack denselben Code in eine Datei unter wp-content/mu-plugins/, häng ihn an init und lass ihn nur bei einem bestimmten Query-Parameter laufen. Ersetze die WP_CLI::log()-Aufrufe durch error_log(). Nach dem Import die Datei löschen, nicht nur auskommentieren.

Ein Slug wird nicht gefunden, obwohl der Eintrag existiert. get_page_by_path() berücksichtigt nur veröffentlichte und private Einträge. Liegt das Ziel noch als Entwurf vor, findest du es über get_posts() mit 'post_status' => 'any'. Anders als get_page_by_title() ist get_page_by_path() nicht veraltet.

Die Werte stehen in der Datenbank, das Frontend zeigt weiter nichts. Prüf zuerst die Referenzzeile mit dem Unterstrich. Stimmt die, liegt es am Objekt-Cache. wp cache flush und ein Durchlauf ohne Seiten-Cache klären das.

Meine Quelle ist ein anderes Feld-Plugin. Der Import ändert sich nicht, nur der Export. Liefert das Quell-Plugin seine Werte nicht über die REST-API, kommst du entweder über wp post meta list auf der alten Installation an die Rohwerte oder du liest sie aus dem gerenderten Frontend. Der erste Weg ist der sauberere, wenn du dort Shell-Zugang hast.

Falls du nicht weiterkommst

Ich mache das beruflich. Schreib mir kurz, worum es geht — ich sag dir ehrlich, ob ich helfen kann.
Zu purin.at
← Alle Beiträge