Migreren van de ene WordPress‑site naar de andere klinkt simpel, maar wie het ooit heeft gedaan weet:
het kan een tijdrovende, foutgevoelige klus zijn. Daarom kiezen steeds meer ontwikkelaars voor automatisering bijvoorbeeld met een Python‑script dat posts, media en metadata volledig automatisch overzet.

Maar is dat altijd de beste keuze? En wanneer is handmatig migreren juist slimmer?

In dit blog neem ik je mee door beide kanten.

Waarom WordPress‑migratie automatiseren met Python

1. Snelheid bij grote hoeveelheden content

Heb je honderden of duizenden posts? Dan is handmatig migreren simpelweg geen optie. Een script haalt posts op via de WordPress REST API, uploadt media opnieuw en zet alles netjes over — zonder dat jij uren hoeft te klikken.

2. Minder menselijke fouten

Copy‑paste is foutgevoelig. Een script doet elke post op exact dezelfde manier.

Dat betekent: consistente titels, content, metadata en featured images.

3. Perfect voor herhaalbare migraties

Werk je met staging‑omgevingen of meerdere WordPress‑installs?

Dan is een script goud waard. Eén druk op de knop en je content staat waar je ’m wilt hebben.

4. Je kunt het uitbreiden zoals je wilt

Wil je ook tags, categorieën, custom fields of zelfs WooCommerce‑producten meenemen?

Python geeft je volledige controle.

Maar… automatiseren is niet altijd de beste keuze

Hoewel een script krachtig is, zijn er situaties waarin handmatig migreren juist verstandiger is.

1. Je oude site is rommelig

Oude shortcodes, kapotte embeds, page‑builder‑restanten, dubbele media… Een script kopieert dat allemaal blind mee. Handmatig migreren geeft je de kans om meteen op te schonen.

2. Je wil de content toch nalopen

Veel bedrijven gebruiken een migratie als moment om:

Dat gaat beter met menselijke ogen.

3. Je hebt veel custom post types of plugin‑data

Sommige plugins gebruiken eigen tabellen of API‑routes.

Een script moet je dan uitbreiden, en dat kost tijd. Soms is handmatig sneller.

4. Je site is klein

Heb je minder dan 50 posts?

Dan is handmatig migreren vaak sneller dan debuggen.

De ideale aanpak: een hybride strategie

De beste migraties combineren het beste van beide werelden:

Zo voorkom je fouten én houd je controle.

Hoe ziet een robuust migratiescript eruit?

Een goed script bevat:

Hiermee verklein je de kans op fouten tot bijna nul.

Het script staat hieronder maar heb ik nog niet getest en is een eerste versie gebouwd door Copilot.

Conclusie: automatiseren is handig — maar niet blindelings

Een Python‑script kan je uren werk besparen en een migratie veel betrouwbaarder maken.

Maar automatiseren is geen doel op zich.

Soms is handmatig migreren juist slimmer, vooral als je content wilt opschonen of als je site complex is.

De beste migratie is degene die:

# =========================
# VOORBEELD MIGRATIE SCRIPT IN PYTHON
# =========================
import requests
import time
import logging
from typing import Optional, Dict, Any, List
# =========================
# CONFIG
# =========================
SOURCE_BASE_URL = "https://oude-site.nl"
DEST_BASE_URL = "https://nieuwe-site.nl"
# Application Passwords of Basic Auth
SOURCE_AUTH = ("source_user", "source_app_password")
DEST_AUTH = ("dest_user", "dest_app_password")
# Migratie-instellingen
BATCH_SIZE = 10 # aantal posts per batch
REQUEST_TIMEOUT = 15 # seconden
MAX_RETRIES = 3
RETRY_SLEEP = 3 # seconden tussen retries
SLEEP_BETWEEN_CALLS = 0.5 # throttle om rate limiting te voorkomen
LOG_FILE = "wp_migratie.log"
# =========================
# LOGGING
# =========================
logging.basicConfig(
filename=LOG_FILE,
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)
# =========================
# HULPFUNCTIES
# =========================
def request_with_retry(
method: str,
url: str,
auth=None,
params: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None,
files: Optional[Dict[str, Any]] = None,
) -> requests.Response:
"""HTTP-call met retries en logging."""
for attempt in range(1, MAX_RETRIES + 1):
try:
resp = requests.request(
method=method,
url=url,
auth=auth,
params=params,
json=json,
files=files,
timeout=REQUEST_TIMEOUT,
)
if 200 <= resp.status_code < 300:
return resp
else:
logger.warning(
f"HTTP {resp.status_code} bij {url} (poging {attempt}/{MAX_RETRIES}) - body: {resp.text[:500]}"
)
except requests.RequestException as e:
logger.error(f"RequestException bij {url} (poging {attempt}/{MAX_RETRIES}): {e}")
if attempt < MAX_RETRIES:
time.sleep(RETRY_SLEEP)
raise RuntimeError(f"Max retries bereikt voor {url}")
def get_all_posts() -> List[Dict[str, Any]]:
"""Haalt alle posts op van de bron-site met paginatie."""
logger.info("Start ophalen posts van bron-site")
posts = []
page = 1
while True:
url = f"{SOURCE_BASE_URL}/wp-json/wp/v2/posts"
params = {
"per_page": 100,
"page": page,
"status": "publish",
}
resp = request_with_retry("GET", url, auth=SOURCE_AUTH, params=params)
batch = resp.json()
if not batch:
break
posts.extend(batch)
logger.info(f"Ophalen posts: pagina {page}, aantal: {len(batch)}")
page += 1
time.sleep(SLEEP_BETWEEN_CALLS)
logger.info(f"Totaal aantal posts opgehaald: {len(posts)}")
return posts
def download_media(media_url: str) -> Optional[bytes]:
"""Downloadt een mediabestand (bijv. featured image)."""
try:
resp = request_with_retry("GET", media_url, auth=SOURCE_AUTH)
return resp.content
except Exception as e:
logger.error(f"Fout bij downloaden media {media_url}: {e}")
return None
def upload_media_to_dest(filename: str, file_bytes: bytes) -> Optional[int]:
"""Uploadt media naar de doelsite en retourneert attachment ID."""
url = f"{DEST_BASE_URL}/wp-json/wp/v2/media"
files = {
"file": (filename, file_bytes),
}
headers = {
"Content-Disposition": f'attachment; filename="{filename}"'
}
try:
resp = request_with_retry("POST", url, auth=DEST_AUTH, files=files)
data = resp.json()
media_id = data.get("id")
logger.info(f"Media geüpload naar doelsite, ID: {media_id}")
return media_id
except Exception as e:
logger.error(f"Fout bij uploaden media naar doelsite: {e}")
return None
def migrate_featured_image(source_post: Dict[str, Any]) -> Optional[int]:
"""Migreert de featured image van een post en retourneert de nieuwe media ID."""
featured_media_id = source_post.get("featured_media")
if not featured_media_id:
return None
# Haal media-info op
media_url = f"{SOURCE_BASE_URL}/wp-json/wp/v2/media/{featured_media_id}"
try:
resp = request_with_retry("GET", media_url, auth=SOURCE_AUTH)
media_data = resp.json()
source_media_url = media_data.get("source_url")
if not source_media_url:
return None
file_bytes = download_media(source_media_url)
if not file_bytes:
return None
filename = source_media_url.split("/")[-1]
new_media_id = upload_media_to_dest(filename, file_bytes)
return new_media_id
except Exception as e:
logger.error(f"Fout bij migreren featured image voor post {source_post.get('id')}: {e}")
return None
def create_post_on_dest(source_post: Dict[str, Any], new_featured_id: Optional[int]) -> Optional[int]:
"""Maakt een nieuwe post aan op de doelsite op basis van de bronpost."""
url = f"{DEST_BASE_URL}/wp-json/wp/v2/posts"
title = source_post.get("title", {}).get("rendered", "")
content = source_post.get("content", {}).get("rendered", "")
excerpt = source_post.get("excerpt", {}).get("rendered", "")
date = source_post.get("date")
payload = {
"title": title,
"content": content,
"excerpt": excerpt,
"status": "publish",
"date": date,
}
if new_featured_id:
payload["featured_media"] = new_featured_id
try:
resp = request_with_retry("POST", url, auth=DEST_AUTH, json=payload)
data = resp.json()
new_id = data.get("id")
logger.info(f"Post aangemaakt op doelsite, nieuwe ID: {new_id}, oude ID: {source_post.get('id')}")
return new_id
except Exception as e:
logger.error(f"Fout bij aanmaken post op doelsite (oude ID {source_post.get('id')}): {e}")
return None
def migrate_posts():
"""Hoofdproces: posts in batches migreren."""
posts = get_all_posts()
total = len(posts)
logger.info("Start migratie van posts")
for i in range(0, total, BATCH_SIZE):
batch = posts[i:i + BATCH_SIZE]
logger.info(f"Verwerken batch {i}–{i + len(batch) - 1} van {total - 1}")
for post in batch:
source_id = post.get("id")
logger.info(f"Start migratie post ID {source_id}")
# 1. Featured image migreren
new_featured_id = migrate_featured_image(post)
# 2. Post aanmaken op doelsite
new_post_id = create_post_on_dest(post, new_featured_id)
if new_post_id:
logger.info(f"Post succesvol gemigreerd: bron ID {source_id} -> doel ID {new_post_id}")
else:
logger.error(f"Post NIET gemigreerd: bron ID {source_id}")
time.sleep(SLEEP_BETWEEN_CALLS)
logger.info(f"Batch {i}–{i + len(batch) - 1} afgerond")
# Kleine pauze tussen batches
time.sleep(2)
logger.info("Migratie voltooid")
if __name__ == "__main__":
logger.info("=== WordPress migratiescript gestart ===")
try:
migrate_posts()
logger.info("=== WordPress migratiescript succesvol afgerond ===")
except Exception as e:
logger.exception(f"Onherstelbare fout in migratiescript: {e}")
print("Er is een ernstige fout opgetreden, zie logbestand voor details.")

Alternatief zonder database:
WP REST Migrator

WP REST Migrator is een open‑source tool die WordPress‑content migreert via de WordPress REST API, één post tegelijk (minder foutgevoelig). Het is gebouwd om grote, rommelige of zwaar belaste WordPress‑sites te migreren zonder traditionele database‑exports of plugin‑gebaseerde migraties die vaak vastlopen bij grote sites.

🚀 Wat het precies doet

WP REST Migrator (WRM) migreert alle content van een WordPress‑site door elk item via de REST API op te halen en opnieuw in te voeren op een nieuwe installatie. Dit omvat:

Het script downloadt media van de oude site en uploadt ze opnieuw naar de nieuwe site, zodat WordPress ze correct indexeert en nieuwe ID’s aanmaakt.

💡 Waarom?

De maker ontwikkelde WP REST Migrator omdat traditionele migratie‑plugins vaak falen bij:

In plaats van een volledige database‑dump te verplaatsen,

migreert WRM alleen de content die je écht nodig hebt,

waardoor je nieuwe WordPress‑installatie schoner en sneller wordt.

🧠 Hoe werkt het?

  1. Het script haalt via de REST API alle posts op.
  2. Het scant elke post op media‑URL’s.
  3. Het downloadt elke afbeelding en uploadt die naar de nieuwe site.
  4. Het maakt nieuwe posts aan op de doelsite met correcte verwijzingen.
  5. Het vervangt oude media‑ID’s door nieuwe.

Dit gebeurt post‑voor‑post, waardoor het robuust is tegen time‑outs en serverbeperkingen.

🛠️ Wat maakt WP REST Migrator uniek?

🧭 Wanneer is WP REST Migrator top?

🆚 Alternatieven

ToolWerkwijzeBeste voor
WP REST MigratorREST API, post‑voor‑postGrote sites, schone migraties
WP Migrate ProDatabase push/pullDev‑teams, staging workflows
WP Migrate LiteDatabase exportKleine tot middelgrote sites
WP‑API Content MigrationREST API via AcornLaravel/Roots‑ecosystem

Geef een reactie

Ontdek meer van Maikel van Esdonk

Abonneer je nu om meer te lezen en toegang te krijgen tot het volledige archief.

Lees verder