How to Export Data from Drupal: Methods, API Limits & Portability
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.
Planning a migration?
Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.
Schedule a free call- 1,500+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
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—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.
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__layoutfield. 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. It is the highest-fidelity programmatic extraction method for Drupal content.
Endpoints follow the pattern /jsonapi/{entity_type}/{bundle}. For example:
# 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/tagsAuthentication 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:
# 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/articleOAuth2 — the recommended method for production extraction pipelines. Install the simple_oauth module, create a Client credential, and exchange for a Bearer token:
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:
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:
# Request only title, body, and created date — skip all other fields
GET /jsonapi/node/article?fields[node--article]=title,body,created,changedOn 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:
GET /jsonapi/node/article?fields[node--article]=title,body,field_tags&include=field_tags&fields[taxonomy_term--tags]=nameThe 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:
GET /jsonapi/node/article?include=uid,field_hero_image,field_tagsFor Paragraphs, you must chain the includes to extract nested content blocks:
GET /jsonapi/node/landing_page?include=field_content_blocks,field_content_blocks.field_imageDeeply 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.
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 crbefore starting a large extraction if data freshness matters.
A Paginated Extraction Script
A basic Python extraction script using the next link pattern:
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:exportinstead. - 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=pathor query thepath_aliastable 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:
composer require drupal/views_data_export
drush en views_data_export -yThen configure in the admin UI:
- Create or edit a View (e.g., a list of Article nodes)
- Add a new Data Export display
- Select your output format (CSV, JSON, XML)
- Add the fields you want exported
- Configure the pager — for large exports, enable batch mode
- Set a path (e.g.,
/export/articles)
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.
# 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.sqlAlways 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:
drush sql:dump --data-only --gzip --result-file=~/data-only.sqlWhen 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:syncto 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, 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_managedtable rows (URIs and metadata), but not the files themselves. You must separately copy thesites/default/filesdirectory.
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.
# 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 archiveThis 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.
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:
# 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_userThe --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_migrationcontrib 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).
# 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_moduleThe 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.
-- 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;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:
-- 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, you must handle file extraction separately:
- Copy the files directory —
sites/default/files/contains all managed public files. Usersync -avz sites/default/files/ /destination/for large transfers. - 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. - 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.uricolumn will shows3://bucket/path/file.jpginstead ofpublic://....
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, 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:
-- Only current revisions: join on n.vid = nfd.vid
WHERE n.vid = nfd.vid AND nfd.status = 1Embedded 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:dumpwith cache table exclusions +rsyncof 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
nidfor 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:
-- 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:
- Inventory your content types — Run
drush entity:listor export your config to see every content type, field, vocabulary, and media type. - 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.
- 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/. - Identify Layout Builder usage —
SELECT COUNT(*) FROM node__layout_builder__layout;tells you the scope of the Layout Builder migration problem. - 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.
- 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.
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.