---
title: "How to Export Data from Drupal: Methods, API Limits & Portability"
slug: how-to-export-data-from-drupal-methods-api-limits-portability
date: 2026-10-12
author: Rishabh Makhar
categories: [Drupal]
excerpt: "Drupal has no single export-everything button. This guide covers JSON:API, Views Data Export, Drush, direct SQL, and how to handle Paragraphs, Media, and API limits."
tldr: "Use JSON:API for programmatic extraction (hard 50-item page limit), Views Data Export for CSV/JSON, and Drush sql:dump for full backups. No single method covers everything — plan to combine at least two."
canonical: https://clonepartner.com/blog/how-to-export-data-from-drupal-methods-api-limits-portability
---

# How to Export Data from Drupal: Methods, API Limits & Portability


# How to Export Data from Drupal: Methods, API Limits & Portability

**Drupal has no single "Export All" button.** Your extraction method depends on your Drupal version (7, 10, 11), the data type you need (content, configuration, files, users), and whether you are migrating to another Drupal instance or leaving the ecosystem entirely.

Drupal stores content in a relational database (MySQL, MariaDB, or PostgreSQL), but its **Entity-Attribute-Value (EAV) schema**—a fragmentation challenge also found when [exporting data from Umbraco](https://clonepartner.com/blog/blog/how-to-export-data-from-umbraco-methods-api-limits-portability)—means a single node's data is spread across many tables: `node`, `node_field_data`, `node_field_revision`, and one `node__field_*` table per field. You cannot run a single `SELECT * FROM articles` query and get a complete, portable content object. Every export method available to you is shaped by this architectural reality.

> [!NOTE]
> **TL;DR:** Use **JSON:API** for programmatic, paginated content extraction (hard-capped at 50 items per page in core). Use **Views Data Export** for admin-driven CSV/JSON/XML exports with batch support. Use **Drush `sql:dump`** for full database snapshots. Use **`drush config:export`** for site configuration as YAML. No single method exports content, configuration, files, users, and media together — plan to combine at least two.

## Drupal Export Methods Compared

| Method | Content (Nodes) | Users | Taxonomy | Files / Media | Config | Translations | Revisions | Best For |
|---|---|---|---|---|---|---|---|---|
| **JSON:API** | ✅ (paginated) | ✅ via `/jsonapi/user/user` | ✅ | Separate download | ❌ | ✅ (with `Accept-Language`) | ❌ | Scripted extraction, decoupled apps |
| **Views Data Export** | ✅ | ✅ | ✅ | ❌ (metadata only) | ❌ | Depends on view config | ❌ | Admin-driven CSV/JSON exports |
| **REST Export (Views)** | ✅ | ✅ | ✅ | ❌ | ❌ | Depends on view config | ❌ | Simple JSON endpoints |
| **Drush `sql:dump`** | ✅ (raw SQL) | ✅ | ✅ | ❌ (references only) | ✅ (in DB) | ✅ | ✅ | Full backups, Drupal-to-Drupal |
| **`drush config:export`** | ❌ | ❌ | ❌ | ❌ | ✅ (YAML) | ❌ | ❌ | Config sync between environments |
| **Default Content** | ✅ (per-entity) | ✅ | ✅ | ✅ (with File Entity) | ❌ | ✅ (2.x) | ❌ | Shipping demo content |
| **Single Content Sync** | ✅ | ✅ | ✅ | ✅ (ZIP archive) | ❌ | ✅ | ❌ | Individual entity export with assets |
| **Migrate API** | ✅ (batch) | ✅ | ✅ | ✅ (references) | ❌ | ✅ | ✅ | Drupal-to-Drupal version upgrades |
| **Direct SQL** | ✅ (raw) | ✅ | ✅ | ❌ (URIs only) | ❌ | ✅ (with langcode filter) | ✅ | Non-Drupal targets, custom ETL |

## Understanding Drupal's Data Architecture

Before running any export, you need to understand how Drupal stores content. Treating Drupal like a flat-file CMS or a simple table structure will result in orphaned data.

- **Entities:** Everything is an entity. Nodes (pages/articles), Users, Taxonomy Terms, and Media are all distinct entities stored in separate database tables.
- **Fields:** Fields are attached to entities. A single Node might pull data from dozens of separate field tables. Every custom field you add to a content type creates a new `node__field_{name}` table.
- **Paragraphs / Entity Reference Revisions:** Modern Drupal relies heavily on the Paragraphs module for layout building. A Node does not contain the text of a paragraph directly — it contains a reference to a Paragraph entity, which may itself contain references to other nested entities. A single article page might reference 15+ paragraph entities, each with their own fields and sub-references. Flattening this into a CSV is nearly impossible without data loss.
- **Layout Builder:** Stores layout data as serialized PHP objects (via Symfony's serializer) in the node's `layout_builder__layout` field. This data is not human-readable and is not portable to any non-Drupal system without custom transformation. See the Layout Builder migration section below for your practical options.
- **Managed Files:** Images and documents are stored on the server (e.g., `sites/default/files`), but their metadata (alt text, file size, usage) is stored in the database as File or Media entities. The physical file and its metadata are decoupled.

## How does JSON:API export work in Drupal?

**Drupal's JSON:API module** (included in core since Drupal 8.7) exposes every content entity as a RESTful endpoint following the [JSON:API specification](https://jsonapi.org/). It is the highest-fidelity programmatic extraction method for Drupal content.

Endpoints follow the pattern `/jsonapi/{entity_type}/{bundle}`. For example:

```bash
# Fetch articles
GET /jsonapi/node/article?page[limit]=50&page[offset]=0

# Fetch users
GET /jsonapi/user/user

# Fetch taxonomy terms from a specific vocabulary
GET /jsonapi/taxonomy_term/tags
```

### Authentication Requirements

This is the most commonly skipped step in extraction scripts. Many Drupal JSON:API endpoints require authentication to access non-public content. Without credentials, you will receive only published nodes with the "access content" permission granted to anonymous users — unpublished content, user data, and any content type restricted to authenticated roles will return empty or 403.

Two authentication methods are supported:

**Cookie-based (session) auth** — suitable for one-off scripts on sites you control:
```bash
# Log in and capture the session cookie
curl -c cookies.txt -X POST https://example.com/user/login \
  -H "Content-Type: application/json" \
  -d '{"name":"admin","pass":"password"}'

# Use the cookie on subsequent requests
curl -b cookies.txt https://example.com/jsonapi/node/article
```

**OAuth2** — the recommended method for production extraction pipelines. Install the `simple_oauth` module, create a Client credential, and exchange for a Bearer token:
```bash
curl -X POST https://example.com/oauth/token \
  -d "grant_type=client_credentials&client_id=YOUR_ID&client_secret=YOUR_SECRET"
```

Include the token in subsequent requests:
```bash
GET /jsonapi/node/article
Authorization: Bearer {access_token}
```

Without addressing authentication, any extraction script targeting a real site will produce incomplete data.

### Reducing Payload Size with Sparse Fieldsets

For large-scale extractions, requesting every field by default is wasteful. JSON:API supports sparse fieldsets via the `?fields` parameter, which dramatically reduces response size and server load:

```text
# Request only title, body, and created date — skip all other fields
GET /jsonapi/node/article?fields[node--article]=title,body,created,changed
```

On a content type with 40 fields, sparse fieldsets can reduce response payload by 60–80%, which meaningfully improves extraction throughput. Combine with `include` to pull only the specific relationship data you need:

```text
GET /jsonapi/node/article?fields[node--article]=title,body,field_tags&include=field_tags&fields[taxonomy_term--tags]=name
```

The `jsonapi_extras` module (a widely-used contrib module on over 50,000 sites) extends JSON:API's default behavior: it allows you to rename endpoints, disable specific resource types, set custom field aliases, and configure computed fields for exposure. If the site you are extracting from has `jsonapi_extras` installed, its endpoint paths or field names may differ from the core defaults — verify before scripting.

### Handling Relationships and Paragraphs

By default, JSON:API returns the UUIDs of related entities, but not their actual content. If your article uses an author (User entity) and a hero image (Media entity), you must use the `include` parameter to fetch them in the same request:

```text
GET /jsonapi/node/article?include=uid,field_hero_image,field_tags
```

For Paragraphs, you must chain the includes to extract nested content blocks:

```text
GET /jsonapi/node/landing_page?include=field_content_blocks,field_content_blocks.field_image
```

Deeply nested paragraph structures quickly hit response size limits, so expect to tune your `include` depth carefully.

### What is the JSON:API page limit in Drupal?

Drupal has a maximum limit of 50 items returned per JSON:API collection page. This is a hard-coded constant in Drupal core's `OffsetPage` class — if you use a URL like `/jsonapi/node/article?page [limit]=51`, the amount of content you get back will still be 50.

For bulk extraction, this means a site with 10,000 articles requires a minimum of 200 sequential API requests just for the node data, before you fetch related entities like media or taxonomy terms.

The `jsonapi_page_limit` contrib module allows you to increase that limit, but the enforced cap of 50 exists specifically because of the performance implications of larger result sets on shared hosting and mid-tier servers.

Pagination uses offset-based parameters: `?page [offset]=0&page [limit]=50`.

> [!WARNING]
> **Offset pagination degrades on large datasets.** As the offset increases, the database must scan and discard progressively more rows. On sites with hundreds of thousands of nodes, requesting `page [offset]=100000` forces MySQL to scan the first 100,000 rows before returning results. For massive datasets, filter by `created` or `changed` timestamps instead of relying purely on offset pagination — this lets you use indexed columns and avoid full table scans.

### JSON:API and Drupal's Page Cache

JSON:API responses for anonymous requests are served through Drupal's Internal Page Cache (for simple requests) or Dynamic Page Cache (for requests with query parameters). For extraction scripts running against authenticated sessions, responses bypass the page cache entirely, which means every request hits PHP and the database.

This has two practical implications:
- **Authenticated extraction is slower** than anonymous — budget accordingly.
- **Repeated identical requests** from anonymous scripts may return stale cached data until the cache is invalidated. Run `drush cr` before starting a large extraction if data freshness matters.

### A Paginated Extraction Script

A basic Python extraction script using the `next` link pattern:

```python
import requests
import time

base_url = "https://example.com/jsonapi/node/article"
headers = {"Authorization": "Bearer YOUR_TOKEN"}
params = {
    "page[limit]": 50,
    "page[offset]": 0,
    "fields[node--article]": "title,body,created,changed,field_tags",
    "include": "field_tags"
}
all_nodes = []

while True:
    resp = requests.get(base_url, headers=headers, params=params)
    resp.raise_for_status()
    data = resp.json()
    all_nodes.extend(data["data"])
    next_link = data.get("links", {}).get("next")
    if not next_link:
        break
    # Follow the 'next' link directly — it includes all pagination params
    base_url = next_link["href"] if isinstance(next_link, dict) else next_link
    params = {}
    time.sleep(0.4)  # ~2.5 req/sec — adjust based on server capacity

print(f"Extracted {len(all_nodes)} nodes")
```

Throttle to 2–3 requests per second on production sites. Drupal core has no built-in rate limiting — the `Rate Limiter` contrib module can enforce limits, but whether it is installed varies by site. Without it, your script's request rate is limited only by the server's capacity.

### What JSON:API does not export

- **Revision history** — JSON:API returns the current published revision only. Accessing older revisions requires direct database queries.
- **Configuration** — Content types, field definitions, views, image styles. Use `drush config:export` instead.
- **File binaries** — JSON:API returns file entity metadata (URI, MIME type, size) but not the file bytes. You must download files separately from their public URLs.
- **URL aliases** — Not included in the default JSON:API response. Add `?include=path` or query the `path_alias` table directly.
- **Inline entity references in body HTML** — Embedded media or entity references within rich text fields are serialized as raw HTML markup containing `<drupal-media data-entity-uuid="...">` tags. These require separate resolution against the media entity index to be meaningful outside Drupal.

## How to export Drupal content with Views Data Export

**Views Data Export** is a contributed module that adds a "Data Export" display to Drupal's Views system, enabling batch export of content to CSV, JSON, XML, or Excel files. It provides a display plugin that can be rendered progressively in a batch, and supports CSV, Microsoft XLS (via the Excel Serialization module), JSON, and XML output formats.

The module is installed on over 105,000 sites, making it the most widely adopted content export tool in the Drupal ecosystem.

### Setup and usage

Install via Composer:

```bash
composer require drupal/views_data_export
drush en views_data_export -y
```

Then configure in the admin UI:

1. Create or edit a View (e.g., a list of Article nodes)
2. Add a new **Data Export** display
3. Select your output format (CSV, JSON, XML)
4. Add the fields you want exported
5. Configure the pager — for large exports, enable batch mode
6. Set a path (e.g., `/export/articles`)

> [!WARNING]
> When using batch mode, sort the view on a unique identifier such as `nid` or `uid` to ensure consistent results. Without a deterministic sort order, records can be duplicated or skipped across batch pages as the underlying query's result set shifts between requests.

### Limitations

Views Data Export struggles with multi-value fields and deeply nested Paragraphs. If an article has five tags, Views will either output five identical rows (one for each tag) or comma-separate them in a single column, depending on your aggregation settings. It cannot natively export a nested tree of Paragraph layout blocks into a usable structured format.

Other gaps:

- **File downloads** — Exports field values and file URIs, not the binary files themselves.
- **Entity references as nested objects** — Related entities (authors, tags) appear as IDs or labels, not as full entity representations.
- **Revision history** — Only the current revision is exported.
- **Body HTML fidelity** — Rich text is exported as raw HTML, including `<drupal-media>` embed tokens that will not resolve on the target platform.

## How to export the Drupal database with Drush

**Drush `sql:dump`** exports the entire Drupal database as a SQL file using `mysqldump` (or the PostgreSQL equivalent). It is the fastest way to create a complete backup, but the output is a database restore artifact, not a migration-ready extract.

```bash
# Clear caches first to reduce dump size
drush cr

# Export with gzip compression, excluding cache tables
drush sql:dump --gzip \
  --skip-tables-list=cache,cache_bootstrap,cache_config,cache_container,cache_data,cache_default,cache_discovery,cache_dynamic_page_cache,cache_entity,cache_menu,cache_page,cache_render,cache_toolbar \
  --result-file=~/drupal-export.sql
```

> [!TIP]
> **Always exclude cache tables from production dumps.** A Drupal site's cache tables can account for 30–60% of the total database size on busy sites. Excluding them reduces dump file size, speeds up the export, and produces a cleaner restore artifact — cache tables are rebuilt automatically on the target environment.

The `--data-only` flag dumps rows without DDL statements to create schema. Useful when importing into an existing schema:

```bash
drush sql:dump --data-only --gzip --result-file=~/data-only.sql
```

### When Drush SQL dump is the right choice

- **Drupal-to-Drupal migration (same major version)** — restoring the dump on a second instance is the fastest complete transfer path.
- **Full backup before a major operation** — captures everything: content, config, users, revisions.
- **Staging environment provisioning** — paired with `drush sql:sync` to copy between environments.

### When Drush SQL dump is the wrong choice

- **Migrating to a non-Drupal platform** — the EAV schema is meaningless to WordPress, Contentful, or any other CMS. Similar to [exporting data from TYPO3](https://clonepartner.com/blog/blog/how-to-export-data-from-typo3-methods-api-limits-portability), you will need to join dozens of tables to reconstruct a single piece of content.
- **Selective content extraction** — you want 500 articles, not 200 database tables. JSON:API or Views Data Export are better fits.
- **Exporting managed files** — the dump contains `file_managed` table rows (URIs and metadata), but not the files themselves. You must separately copy the `sites/default/files` directory.

If you are using the **Backup and Migrate** module, you can configure scheduled backups of both the database and the public files directory from the Drupal admin UI. Be aware that restoring a 50GB `files` directory via a web-based PHP upload will fail — large file migrations must be handled via rsync or SSH.

## How to export Drupal configuration

**Drupal's Configuration Management system** exports site structure — content types, fields, views, image styles, permissions, workflows — as YAML files. This capability is built into core and accessible via Drush or the admin UI.

```bash
# Full config export via Drush
drush config:export
# Writes YAML files to your config/sync directory (defined in settings.php)

# Or via the admin UI:
# Configuration > Development > Configuration Synchronization > Export > Full archive
```

This is essential for any migration: without the configuration export, you cannot reconstruct the content model on the destination platform. The YAML files define what fields exist, what widgets are used, and how entities relate to each other. A content type with 20 fields will produce approximately 25–30 YAML files covering the field storage definitions, field instance configurations, form display settings, and view display settings.

> [!TIP]
> Configuration export does **not** include content (nodes, users, taxonomy terms, files). It only exports the *structure*. You need a separate method for the actual data.

## Drupal's Migrate API for Version-to-Version Migration

The Migrate API is Drupal's built-in framework for structured, batch content migration. Unlike JSON:API (which is a read API) or sql:dump (which is a raw snapshot), the Migrate API is specifically designed to transform and move content between Drupal instances — including across major version differences.

It deserves its own section because it is the correct tool for Drupal-to-Drupal version upgrades, and using anything else (Views Data Export, manual SQL) for this scenario adds unnecessary complexity.

### How it works

The Migrate API uses a source-process-destination pipeline:
- **Source plugins** read from the origin database (including D7 table structures like `field_data_field_{name}`)
- **Process plugins** transform field values (e.g., converting D7 file references to D10 Media entities)
- **Destination plugins** write to the target Drupal instance

For upgrading from Drupal 7 to Drupal 10/11, run the upgrade wizard from the admin UI or use Drush:

```bash
# On the destination D10 site, pointing at the D7 source database
drush migrate:upgrade --legacy-db-url=mysql://user:pass@host/d7_db --legacy-root=/path/to/d7/files

# Run all migrations
drush migrate:import --all

# Or run specific migrations
drush migrate:import upgrade_d7_node_article
drush migrate:import upgrade_d7_file
drush migrate:import upgrade_d7_user
```

The `--legacy-root` parameter tells Drush where to find the D7 files directory so managed files are copied during migration.

### Media Migration for Drupal 7

The **Media Migration** module handles the D7-to-D10 path for file and media entities. Drupal 7 media entities (including File entities and `media_wysiwyg` embeds) are migrated into Drupal core media entities based on the source file type. Simple D7 managed files are also migrated into core media entities, and the inline `[[{"fid":"123",...}]]` WYSIWYG tokens in body fields are converted to `<drupal-media data-entity-uuid="...">` tags automatically.

### Migrate API limitations

- **Not designed for non-Drupal targets** — the destination must be a Drupal instance.
- **Revisions** are migrated but can significantly increase migration time on sites with deep revision histories.
- **Custom entity types and fields** require custom source and process plugins — the core migrate modules only cover standard Drupal entity types.
- **Paragraphs** require the `paragraphs_migration` contrib module for full fidelity.

## How to export individual Drupal entities

For targeted, per-entity exports — shipping demo content, recovering specific pages, or syncing between environments — two contributed modules stand out.

### Default Content module

Default Content gives modules and install profiles a way to ship default content as well as configuration, leveraging core's Serialization, REST, and HAL modules. It supports entity references between content (as well as files if you have File Entity).

```bash
# Export a single node
drush default-content:export node 123

# Export all content of a type into a module
drush default-content:export-references node 123 my_export_module
```

The 2.x branch exports as YAML instead of HAL+JSON, with support for translations and files stored alongside the normalized entity files. Any module that requires default content can place YAML versions of the entities inside `content/{entity_type}` folders.

### Single Content Sync module

The Single Content Sync module offers a way to export any content (node, taxonomy, media, users, blocks, paragraphs, etc.) and import it to a different environment or a different website, even if it has a totally different structure.

The module generates a YAML snippet for the content item that can be copied from one site and pasted into another. It can also generate a ZIP archive that contains assets and one or multiple exported entities.

Single Content Sync is the better choice when you need file assets bundled with the content export. Default Content is better for developer workflows where content ships inside a module.

## Understanding Drupal's database schema for direct SQL export

If you have direct database access and need to extract content for a non-Drupal target, you must understand the table structure. Drupal does not store content in a single table per content type — the EAV schema distributes a single node's data across many tables.

For a basic Article node, the data lives in at minimum these tables:

| Table | What it stores |
|---|---|
| `node` | Entity ID (`nid`), revision ID (`vid`), content type (`type`), UUID |
| `node_field_data` | Title, status, created/changed timestamps, author (`uid`), language (`langcode`) |
| `node_field_revision` | Per-revision copies of title, status, etc. |
| `node__body` | Body field value, format, summary (one row per node per delta per language) |
| `node__field_tags` | Entity reference to taxonomy terms (one row per tag per node) |
| `node__field_image` | File ID reference, alt text, dimensions |
| `file_managed` | File URI, MIME type, size, status |
| `taxonomy_term_field_data` | Term name, description, vocabulary ID |
| `path_alias` | URL alias mapped to `/node/{nid}` |
| `users_field_data` | Username, email, created, roles |

A content type with 20 custom fields means 20+ additional tables that must be joined to reconstruct a single node.

```sql
-- Example: Export articles with body and author (default language, current revision only)
SELECT
  n.nid,
  n.uuid,
  nfd.title,
  nfd.status,
  nfd.created,
  nfd.changed,
  nfd.langcode,
  u.name AS author,
  u.mail AS author_email,
  nb.body_value,
  nb.body_summary,
  nb.body_format,
  pa.alias AS url_alias
FROM node n
JOIN node_field_data nfd ON n.nid = nfd.nid AND nfd.default_langcode = 1
JOIN users_field_data u ON nfd.uid = u.uid
LEFT JOIN node__body nb ON n.nid = nb.entity_id AND nb.deleted = 0 AND nb.langcode = nfd.langcode
LEFT JOIN path_alias pa ON pa.path = CONCAT('/node/', n.nid) AND pa.langcode = nfd.langcode
WHERE n.type = 'article'
  AND nfd.vid = n.vid  -- current revision only
ORDER BY n.nid;
```

> [!WARNING]
> This query returns only the default language version and only the current revision. Adding translations requires a UNION or additional JOIN on `langcode`. Adding multi-value fields (like tags) requires a separate JOIN per field, which multiplies result rows by cardinality. For a node with 5 tags, a naive JOIN on `node__field_tags` produces 5 result rows for that node.

**Drupal 7 table naming convention differs:** D7 uses `field_data_field_{name}` and `field_revision_field_{name}` instead of `node__field_{name}` and `node_revision__field_{name}`. A D7 article body query joins `field_data_body` instead of `node__body`. Factor this into any SQL-based extraction targeting D7 databases.

To count records before scripting a full extraction:

```sql
-- Record counts by content type
SELECT type, COUNT(*) as node_count FROM node GROUP BY type ORDER BY node_count DESC;

-- File storage inventory
SELECT COUNT(*) as file_count, ROUND(SUM(filesize)/1024/1024, 1) as total_mb FROM file_managed WHERE status = 1;

-- Translation coverage
SELECT langcode, COUNT(*) FROM node_field_data GROUP BY langcode;
```

## How to export files and media from Drupal

Drupal manages files through a two-layer abstraction: **File entities** (the actual file reference in `file_managed`) and **Media entities** (a content wrapper around the file, introduced in Drupal 8.4+ and part of core from Drupal 8.7).

No export method — JSON:API, Views Data Export, or Default Content — automatically bundles the binary files into the export. Just as with [Kentico media extractions](https://clonepartner.com/blog/blog/how-to-export-data-from-kentico-extraction-methods-api-limits-portability), you must handle file extraction separately:

1. **Copy the files directory** — `sites/default/files/` contains all managed public files. Use `rsync -avz sites/default/files/ /destination/` for large transfers.
2. **Use the file URIs from your data export** — Every content export includes file references as `public://2024-03/photo.jpg`. Map these to the actual filesystem path: `sites/default/files/2024-03/photo.jpg`.
3. **For remote/CDN-hosted files** — If the site uses the S3 File System or Azure Blob Storage modules with a remote stream wrapper, download files from those remote URLs, not from the local filesystem. The `file_managed.uri` column will show `s3://bucket/path/file.jpg` instead of `public://...`.

If your files are stored behind Drupal's **private file system** (`private://`), they cannot be accessed via direct URL. Your extraction script must authenticate and request files through Drupal's routing system (`/system/files/...`), or access the private file directory directly via the filesystem path defined in `$settings ['file_private_path']` in `settings.php`.

## Drupal 7 vs. Drupal 10/11 export differences

Drupal 7 reached its final End of Life (EOL) in January 2025. If you are still on Drupal 7, your export options are significantly more limited.

| Capability | Drupal 7 | Drupal 10/11 |
|---|---|---|
| JSON:API | ❌ (not available) | ✅ Core module (since 8.7) |
| REST API | Contrib only (`restws`, `services`) | ✅ Core module |
| Views Data Export | ✅ (D7 version) | ✅ (D10/11 compatible) |
| Config Management | ❌ (Features module, partial) | ✅ Core (`drush config:export`) |
| Drush sql:dump | ✅ | ✅ |
| Default Content | ❌ | ✅ |
| Media entities | ❌ (File entities only) | ✅ Core |
| Field table naming | `field_data_field_{name}` | `node__field_{name}` |
| Inline media in body | `[[{"fid":"123",...}]]` tokens | `<drupal-media data-entity-uuid="...">` tags |
| Authentication API | Session cookie only | Session cookie + OAuth2 |

Drupal 7 sites typically require direct database queries, the Views Data Export module, or the Services module for programmatic export. The Services module can expose REST endpoints but requires significant manual configuration. Direct SQL against D7's `field_data_*` tables is often the most pragmatic path for non-Drupal targets, since D7's schema is well-documented and the join logic is consistent across installations.

## Common data export pitfalls

### Multilingual content

Drupal stores translations as separate rows in `node_field_data` (keyed by `langcode`) and separate rows in each `node__field_*` table. JSON:API returns the default language unless you explicitly request a translation using the `Accept-Language: fr` header. If you have content in 5 languages, plan for 5× the API calls — or use direct database queries with language-aware filters (`WHERE langcode = 'fr'`). Verify translation completeness before migration: `SELECT nid, langcode FROM node_field_data WHERE type = 'article' ORDER BY nid` shows you exactly which nodes have which translations.

### Paragraph and Layout Builder content

Sites using the Paragraphs module store structured content as separate Paragraph entities referenced from nodes. JSON:API can include these via `?include=field_content`, but deeply nested paragraph structures quickly hit response size limits. The `paragraphs_migration` module is required for Migrate API fidelity.

Layout Builder data is stored as serialized PHP objects in `node__layout_builder__layout`. When [migrating to a non-Drupal platform](https://clonepartner.com/blog/cms-migration/drupal), you have three practical options: (1) convert the node to use a standard body field before export, using a Drush script to flatten the layout into HTML; (2) write a custom PHP script that unserializes the layout data, traverses the section/component tree, and extracts text values; or (3) treat Layout Builder pages as a manual migration — export the final rendered HTML via headless rendering and clean it up for the target system. None of these paths is automatic; budget significant engineering time for Layout Builder-heavy sites.

### Revision explosion

Drupal keeps every saved revision by default. A site with 5,000 nodes and an average of 20 revisions per node has 100,000 revision rows in `node_field_revision` alone, plus equivalent rows in every `node_revision__field_*` table. If you only need the current published version, exclude revision tables from your database dump using `--skip-tables-list` or filter by `vid` in your queries:

```sql
-- Only current revisions: join on n.vid = nfd.vid
WHERE n.vid = nfd.vid AND nfd.status = 1
```

### Embedded media in WYSIWYG

Drupal 8+ uses `<drupal-media data-entity-uuid="abc-123">` tags in body fields to embed media. If you export the body HTML without also exporting and mapping the corresponding media entities, those embeds will produce broken markup on the target platform. Your extraction pipeline must: (1) parse all `data-entity-uuid` values from body fields; (2) resolve them against the media entity index; (3) download the corresponding files; and (4) rewrite the embed tags to standard `<img>` or `<figure>` elements for the target system.

Drupal 7 sites using Media WYSIWYG store `[[{"fid":"123","view_mode":"default",...}]]` tokens that require the same resolution process using the `file_managed` table's `fid` column.

### The Feeds Module for Import/Export Workflows

If your use case involves recurring import/export rather than a one-time migration, the **Feeds** module supports bidirectional content synchronization via CSV, RSS, JSON, and XML. Feeds can both import content into Drupal and export content for consumption by external systems. It is not suitable for a full site migration but is appropriate for ongoing ETL workflows where content flows between Drupal and an external data source on a schedule.

## Which export method should you use?

The right method depends on your destination and data requirements:

- **Drupal-to-Drupal (same version):** `drush sql:dump` with cache table exclusions + `rsync` of the files directory. Fastest, most complete.
- **Drupal-to-Drupal (version upgrade, e.g., D7 to D10):** Migrate API (`drush migrate:upgrade` + `drush migrate:import --all`). Handles schema transformation, file migration, and media entity conversion automatically.
- **Drupal to a non-Drupal CMS:** JSON:API with OAuth2 auth and sparse fieldsets for structured content extraction, plus filesystem copy or remote download for files. Expect to write transformation scripts for the target format.
- **Reporting or analytics:** Views Data Export to CSV. Quick, admin-friendly, no code required. Sort on `nid` for consistent batch results.
- **Archival:** Database dump (excluding cache tables) + files directory + config export. Three artifacts that together can fully reconstruct the site.
- **Selective content sync:** Default Content or Single Content Sync for individual entities with references and bundled assets.
- **Recurring ETL:** Feeds module for scheduled import/export workflows.

## Preparing a Drupal export for platform migration

If you are migrating away from Drupal, the data extraction is the first step in a longer process. Run these inventory queries before writing any extraction code:

```sql
-- 1. Content volume by type
SELECT type, COUNT(*) as node_count FROM node GROUP BY type ORDER BY node_count DESC;

-- 2. File storage inventory
SELECT COUNT(*) as file_count, ROUND(SUM(filesize)/1024/1024, 1) as total_mb
FROM file_managed WHERE status = 1;

-- 3. Translation coverage by content type and language
SELECT nfd.type, nfd.langcode, COUNT(*) as count
FROM node_field_data nfd
GROUP BY nfd.type, nfd.langcode
ORDER BY nfd.type, count DESC;

-- 4. Revision depth (identify high-revision nodes)
SELECT type, AVG(rev_count) as avg_revisions, MAX(rev_count) as max_revisions
FROM (
  SELECT n.type, n.nid, COUNT(nfr.vid) as rev_count
  FROM node n
  JOIN node_field_revision nfr ON n.nid = nfr.nid
  GROUP BY n.nid
) sub
GROUP BY type;

-- 5. Paragraph usage (if applicable)
SELECT COUNT(*) as paragraph_count FROM paragraphs_item;
```

Then follow this pre-migration checklist:

1. **Inventory your content types** — Run `drush entity:list` or export your config to see every content type, field, vocabulary, and media type.
2. **Map your fields** — For each content type, document every field name, type, cardinality, and whether it stores entity references. This becomes your field mapping document for the target platform.
3. **Check for custom modules** — Custom field formatters, computed fields, and hook implementations may store or transform data in ways not visible in the standard schema. Search for hook implementations in custom modules: `grep -r "hook_node_" modules/custom/`.
4. **Identify Layout Builder usage** — `SELECT COUNT(*) FROM node__layout_builder__layout;` tells you the scope of the Layout Builder migration problem.
5. **Test a sample extraction** — Extract 50–100 nodes via JSON:API and validate the output against your field map before scripting a full extraction. Pay attention to multi-value fields and entity reference resolution.
6. **Plan for file transfer separately** — File transfers are typically the longest-running step. A 50GB files directory transfers at approximately 10–50 MB/s via rsync over a good connection — budget 15 minutes to 1.5 hours for the transfer alone, not counting retries.

> **Need to get data out of Drupal?** Whether you are migrating 500 articles or 50,000 nodes with paragraphs and multilingual content, ClonePartner's engineers have built extraction pipelines for Drupal 7, 10, and 11 — handling the schema complexity, file exports, and edge cases so you don't have to. Book a free 30-minute scoping call and get a fixed-price proposal.
>
> [Talk to us](https://clonepartner.com/talk-to-us?duration=30&utm_source=blog&utm_medium=button&utm_campaign=demo_bookings&utm_content=cta_click&utm_term=demo_button_click)

## Frequently asked questions

### What is the JSON:API page limit in Drupal?

Drupal core hard-codes a maximum of 50 items per JSON:API collection page. Even if you request page[limit]=500, the response will contain at most 50 resources. The jsonapi_page_limit contrib module can increase this, but higher limits have performance implications — which is why core enforces the cap.

### How do I export all content from Drupal to CSV?

Install the Views Data Export module (composer require drupal/views_data_export), create a View for the content type you want to export, add a Data Export display, select CSV format, and configure the fields. Enable batch mode for large datasets and sort by a unique ID like nid for consistent results.

### Can I export Drupal content with files and images included?

No single Drupal export method bundles binary files with content data. You must export content via JSON:API or Views Data Export, then separately copy the sites/default/files/ directory (or download from your CDN). The Single Content Sync module can generate a ZIP archive that includes assets alongside entity YAML data.

### How do I export Drupal Paragraphs?

Paragraphs cannot be easily exported to flat formats like CSV. Use JSON:API and append the include query parameter (e.g., ?include=field_paragraphs,field_paragraphs.field_image) to fetch nested paragraph entities alongside the parent node. Deeply nested structures may hit response size limits.

### Does Drupal have API rate limiting for data extraction?

Drupal core does not include built-in API rate limiting. Rate limiting depends on whether the site administrator has installed contrib modules like Rate Limiter. When extracting from a production site, self-throttle to 2-3 requests per second to avoid degrading performance.
