🚀 Executive Summary

TL;DR: This guide details a refined process for migrating content from a legacy Drupal site to a modern, headless Contentful CMS using a Python script. The core problem addressed is the bottleneck created by Drupal monoliths for content changes, solved by automating data extraction, transformation, and loading into Contentful to free up engineering resources and empower marketing teams.

🎯 Key Takeaways

  • Prioritize content model mapping from Drupal Content Types to Contentful Content Models as the most critical initial step.
  • Utilize a Python 3 environment with `requests`, `python-dotenv`, and `contentful_management` libraries for building the migration script.
  • Extract content from Drupal using its JSON:API, ensuring pagination is handled for large datasets.
  • Implement a data transformation function to map Drupal’s data structure to Contentful’s expected format, including a `drupal_uuid` field for idempotency.
  • Push transformed data to Contentful via the Content Management API, remembering to publish entries and manage potential API rate limits.

Migrate Drupal Content to Contentful (Headless CMS)

Migrate Drupal Content to Contentful (Headless CMS)

Hey team, Darian here.

I want to walk you through a process I’ve refined over several projects: migrating a legacy Drupal site to a modern, headless setup with Contentful. I remember one project where our marketing team was constantly blocked by dev cycles for simple content changes on our old Drupal monolith. We were the bottleneck. Scripting this migration process was the key to unlocking their speed and freeing up our engineering resources for more complex problems. It’s a bit of up-front work that pays dividends for months.

Let’s dive in.

Prerequisites

Before we start, make sure you have the following ready to go:

  • Access to your Drupal instance: We’ll be using the JSON:API module, which is standard in Drupal 8+. If you have direct database access, that’s another route, but the API is cleaner.
  • A Contentful Account: You’ll need a Space created for your new content.
  • Contentful Credentials: Specifically, your Space ID and a Content Management API (CMA) token. You can generate this in your Contentful space under Settings -> API keys.
  • A Python 3 Environment: This is where we’ll build our migration script.

The Step-by-Step Guide

Step 1: The Blueprint – Map Your Content Models

This is the most critical step, and it happens away from the keyboard. Before you migrate a single post, you need to map your Drupal Content Types to your new Contentful Content Models.

For example, a Drupal ‘Article’ content type might have fields like `title`, `field_body`, `field_author`, and `field_hero_image`. You need to create a corresponding ‘Article’ Content Model in Contentful with fields like `title` (Short Text), `body` (Rich Text), `author` (Reference), and `heroImage` (Media).

Don’t skip this. A clear plan here prevents a massive headache later.

Step 2: Prepping the Environment

Alright, let’s get our project folder ready. I’ll skip the standard virtualenv setup since you likely have your own workflow for that. The key is to get the necessary Python libraries installed. You’ll need `requests` (for hitting the Drupal API), `python-dotenv` (for managing secrets), and `contentful_management` (the official client for Contentful).

Next, create a file in your project root named `config.env`. This is where we’ll safely store our credentials. Never commit this file to source control.


# config.env

DRUPAL_API_ENDPOINT="https://your-drupal-site.com/jsonapi/node/article"
CONTENTFUL_SPACE_ID="your_space_id"
CONTENTFUL_CMA_TOKEN="your_cma_token"
CONTENTFUL_ENVIRONMENT="master"

Step 3: Extracting Content from Drupal

Now for some code. We’ll start by writing a function to fetch all the ‘article’ nodes from our Drupal site using its JSON:API. This is a simple GET request.


import requests
import os
from dotenv import load_dotenv

load_dotenv('config.env')

DRUPAL_API_ENDPOINT = os.getenv('DRUPAL_API_ENDPOINT')

def fetch_drupal_articles():
    """Fetches all articles from the Drupal JSON:API endpoint."""
    all_articles = []
    url = DRUPAL_API_ENDPOINT
    
    print("Fetching data from Drupal...")
    
    while url:
        try:
            response = requests.get(url)
            response.raise_for_status()  # Raises an exception for bad status codes
            data = response.json()
            
            all_articles.extend(data.get('data', []))
            
            # Drupal's JSON:API uses pagination
            if 'next' in data.get('links', {}):
                url = data['links']['next']['href']
            else:
                url = None
                
        except requests.exceptions.RequestException as e:
            print(f"Error fetching from Drupal: {e}")
            return []
            
    print(f"Successfully fetched {len(all_articles)} articles.")
    return all_articles

This function handles pagination automatically, which is crucial for sites with a lot of content.

Step 4: Transforming the Data for Contentful

The data structure from Drupal won’t match what Contentful expects. We need a “transformer” function to map the fields. This is where your plan from Step 1 becomes code.

In this example, I’m mapping the Drupal `title` and `body.value` to the corresponding Contentful fields.


def transform_for_contentful(article_data):
    """Transforms a single Drupal article into a Contentful-friendly format."""
    attributes = article_data.get('attributes', {})
    
    # This is a simple transformation. Yours might be more complex,
    # especially when dealing with images, tags, or other relationships.
    transformed_payload = {
        'title': {
            'en-US': attributes.get('title', 'Untitled')
        },
        'body': {
            'en-US': {
                'nodeType': 'document',
                'data': {},
                'content': [
                    {
                        'nodeType': 'paragraph',
                        'data': {},
                        'content': [
                            {
                                'nodeType': 'text',
                                'value': attributes.get('body', {}).get('value', ''),
                                'marks': [],
                                'data': {}
                            }
                        ]
                    }
                ]
            }
        },
        # We'll use the Drupal UUID to prevent duplicates
        'drupal_uuid': {
            'en-US': article_data.get('id')
        }
    }
    return transformed_payload

Pro Tip: Notice the `drupal_uuid` field. I always add a plain text field to my Contentful model to store the original ID from the source system (like the Drupal node UUID). This makes the script idempotent—meaning you can run it multiple times without creating duplicate entries. We’ll use this in the next step.

Step 5: Loading into Contentful

Finally, we connect to Contentful and push our transformed data. The `contentful_management` library makes this straightforward. Our script will loop through each article, transform it, check if it already exists (using our `drupal_uuid`), and then create or update it.


import contentful_management

CONTENTFUL_SPACE_ID = os.getenv('CONTENTFUL_SPACE_ID')
CONTENTFUL_CMA_TOKEN = os.getenv('CONTENTFUL_CMA_TOKEN')
CONTENTFUL_ENVIRONMENT = os.getenv('CONTENTFUL_ENVIRONMENT')

def push_to_contentful(articles):
    """Pushes transformed articles to Contentful, avoiding duplicates."""
    client = contentful_management.Client(CONTENTFUL_CMA_TOKEN)
    content_type_id = 'article' # The ID of your Contentful content model

    for i, article in enumerate(articles):
        drupal_uuid = article.get('id')
        print(f"Processing article {i+1}/{len(articles)} (UUID: {drupal_uuid})...")
        
        # Check if entry already exists
        try:
            existing_entries = client.entries(
                CONTENTFUL_SPACE_ID,
                CONTENTFUL_ENVIRONMENT
            ).find_all({'content_type': content_type_id, 'fields.drupal_uuid[in]': drupal_uuid})

            if len(list(existing_entries)) > 0:
                print(f"Article with UUID {drupal_uuid} already exists. Skipping.")
                continue

        except Exception as e:
            print(f"Could not check for existing entry: {e}")
            continue

        # If not found, transform and create
        payload = transform_for_contentful(article)
        
        try:
            new_entry = client.entries(
                CONTENTFUL_SPACE_ID, 
                CONTENTFUL_ENVIRONMENT
            ).create(
                None,  # Let Contentful generate the entry ID
                {
                    'content_type_id': content_type_id,
                    'fields': payload
                }
            )
            print(f"  -> Created entry with ID: {new_entry.id}")
            
            # Don't forget to publish!
            new_entry.publish()
            print(f"  -> Published entry.")

        except Exception as e:
            print(f"  -> Failed to create or publish entry for UUID {drupal_uuid}: {e}")

# --- Main execution block ---
if __name__ == "__main__":
    drupal_articles = fetch_drupal_articles()
    if drupal_articles:
        push_to_contentful(drupal_articles)
    print("Migration script finished.")

Common Pitfalls (Where I Usually Mess Up)

  • API Rate Limits: If you have thousands of entries, you’ll hit Contentful’s API rate limit. Implement a `time.sleep(0.1)` in your loop to slow things down. My production scripts always have this.
  • Rich Text & Media: Migrating simple text is easy. Migrating HTML from Drupal’s body field, with its embedded images and complex tags, is hard. You’ll likely need a library like `BeautifulSoup` to parse the HTML and convert it to Contentful’s Rich Text JSON structure. Asset migration (images, PDFs) is a whole separate script where you download from Drupal and upload to Contentful Assets first.
  • Forgetting to Publish: The API creates entries as drafts by default. If you forget the `.publish()` call, you’ll be wondering why nothing is showing up on your front end.
  • Mismatched Fields: The script will fail if you try to push a string to a number field, or if a required field in Contentful is missing from your payload. Double-check your mapping from Step 1.

Conclusion

And that’s the core workflow. This script is a solid foundation you can adapt for different content types and more complex field mappings involving linked entries and assets. By automating the migration, you’re not just moving data; you’re fundamentally changing how your teams collaborate and ship new experiences. It’s a powerful enabler for any team looking to adopt a modern, headless architecture.

Let me know if you run into any issues.

-Darian

Darian Vance - Lead Cloud Architect

Darian Vance

Lead Cloud Architect & DevOps Strategist

With over 12 years in system architecture and automation, Darian specializes in simplifying complex cloud infrastructures. An advocate for open-source solutions, he founded TechResolve to provide engineers with actionable, battle-tested troubleshooting guides and robust software alternatives.


🤖 Frequently Asked Questions

âť“ How do I migrate Drupal content to Contentful using a script?

Migrating Drupal content to Contentful involves mapping Drupal content types to Contentful models, extracting data via Drupal’s JSON:API, transforming the data into Contentful’s expected JSON format, and then using the `contentful_management` Python library to create and publish entries in Contentful, often incorporating a `drupal_uuid` for idempotency.

âť“ How does this Python-scripted migration compare to other migration tools or manual processes?

This Python-scripted approach offers high customization and direct control over data transformation and loading, making it ideal for complex or specific migration needs. It contrasts with manual processes which are time-consuming and error-prone, and with generic third-party tools that might lack the flexibility for intricate field mappings or specific content structures from Drupal.

âť“ What are common implementation pitfalls when migrating Drupal content to Contentful?

Common pitfalls include hitting Contentful API rate limits (requiring rate limiting in the script), complex migration of Rich Text and media assets (often needing `BeautifulSoup` and separate asset scripts), forgetting to publish entries (which default to draft status), and mismatched fields between Drupal and Contentful content models leading to data type errors.

Leave a Reply

Discover more from TechResolve - SaaS Troubleshooting & Software Alternatives

Subscribe now to keep reading and get access to the full archive.

Continue reading