Exporting data from WordPress does not always require a plugin. If you need posts, users, form submissions, or WooCommerce orders in a spreadsheet, the right method depends on dataset size, who consumes the file, and how often the job runs.
Use plain CSV for most exports, stream rows with fputcsv() for browser downloads, batch with WP_Query, and switch to WP-CLI when an admin request would hit memory or timeout limits. Reach for the REST API when another system needs structured data rather than a file.
Best way to export WordPress data to CSV
Three approaches cover almost every WordPress export job:
- PHP inside WordPress for capability-checked admin downloads
- WP-CLI for large datasets and scheduled server jobs
- REST API for dashboards, warehouses, CRMs, and headless consumers
Occasional small exports fit a PHP download well. Recurring or multi-gigabyte-adjacent tables belong on the CLI. External systems should pull JSON over REST and shape CSV outside WordPress when they need a spreadsheet.
Match method to failure mode. Browser downloads fail on timeouts. Plugins fail when they try to build a full spreadsheet object in one request. Ad-hoc SQL dumps fail when column names drift after a plugin update. Pick the path that keeps the export reproducible next month, not only today.
Need a custom exporter wired into your stack? Talk to a WordPress developer who has shipped production exports before.
What Tools > Export in WordPress actually exports
WordPress ships an exporter in core, but it does not produce a spreadsheet. According to the Tools Export screen documentation, the downloaded file is an XML file in WordPress eXtended RSS (WXR) format containing posts, pages, custom post types, comments, custom fields, categories, tags, custom taxonomies, and users. When you pick posts or pages, you can filter by category, author, date range, and status.
That file is meant for Tools > Import > WordPress on another install. Excel can open the XML, but every entry drags along the full content HTML, the metadata, and the comments, so it is of little use as a spreadsheet. Use WXR for migrations and content copies. Use CSV when a person has to filter, sum, or cross-reference columns.
The same WXR comes out on the server with wp export. It accepts filters such as --post_type, --start_date and --end_date (YYYY-MM-DD format), --post_status, and --author, splits files at 15 MB by default (--max_file_size, with -1 for no limit), and with --stdout writes the XML to standard output:
wp export --dir=/tmp/exports/ --post_type=post --start_date=2026-01-01 --end_date=2026-06-30CSV or XLSX for WordPress data exports
Start with CSV unless a stakeholder has a hard requirement for .xlsx.
CSV strengths
- Easy to generate with PHP or WP-CLI
- Easy to inspect and debug in a text editor
- Opens in Excel, Numbers, and Google Sheets
- Avoids XLSX libraries and their memory cost
XLSX trade-offs
- Binary format with worksheets, styles, and shared strings
- Needs a library such as PhpSpreadsheet or a post-export conversion step
- Builds more state in memory before the file is complete
- Harder to stream safely during a long HTTP request
Practical pattern: export CSV from WordPress, then convert with Excel, LibreOffice, or a small offline script if someone insists on XLSX. That keeps the WordPress request lean.
Excel tip: UTF-8 CSV files sometimes need a BOM (\xEF\xBB\xBF) so Excel on Windows opens diacritics correctly. Microsoft support confirms it: a UTF-8 CSV saved with a BOM opens normally on double-click. Without a BOM, the documented route is Data > Get Data > From File > From Text/CSV, which lets the user pick the encoding before loading. Write the BOM before the header row when your audience lives in Excel, and leave it out when the file feeds another script, because many parsers read those three bytes as part of the first column name.
Export WordPress users to CSV with PHP
For custom admin tools, stream CSV to the browser. Capability and nonce checks come first. Then set headers, open php://output, write the header row, and stream data rows page by page.
add_action( 'admin_post_wppoland_export_users', 'wppoland_export_users_csv' );
function wppoland_export_users_csv() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Access denied.' );
}
check_admin_referer( 'wppoland_export_users' );
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename=users-export.csv' );
$output = fopen( 'php://output', 'w' );
// Optional BOM for Excel on Windows:
// fwrite( $output, "\xEF\xBB\xBF" );
fputcsv( $output, array( 'ID', 'Email', 'Registered' ), ',', '"', '' );
$page = 1;
do {
$users = get_users(
array(
'fields' => array( 'ID', 'user_email', 'user_registered' ),
'number' => 500,
'paged' => $page,
'orderby' => 'ID',
'order' => 'ASC',
)
);
foreach ( $users as $user ) {
fputcsv(
$output,
array( $user->ID, $user->user_email, $user->user_registered ),
',',
'"',
''
);
}
$page++;
} while ( count( $users ) === 500 );
fclose( $output );
exit;
}This pattern works for smaller exports and internal admin tools. Do not collect every row into a giant PHP array and then implode it. Stream each row as you fetch it. A single get_users() call with number => 500 would return only the first 500 users; the loop with paged walks the rest in pages of the same size.
Protect the endpoint with a nonce and a capability check. The form that triggers the download must include wp_nonce_field( 'wppoland_export_users' ) so check_admin_referer() can validate it. User and order exports often contain personal data, so treat them like any other privileged admin action under GDPR-style rules: log who ran the export and keep the file off public URLs.
fputcsv in PHP 8.4 and the escape parameter
The examples in this guide pass five arguments to fputcsv(), and that is deliberate. The PHP manual states that as of PHP 8.4.0, relying on the default value of escape is deprecated: it has to be passed explicitly, positionally or as a named argument. An export that runs on every cron tick would otherwise fill the log with deprecation notices.
The same manual explains that any escape other than the empty string can produce CSV that is not RFC 4180 compliant or that cannot survive a roundtrip through the PHP CSV functions. The default is a backslash, and the recommendation is to set it to "". That is why every call here uses ',', '"', ''. The manual also warns that the default will change in a future version, no earlier than PHP 9.0, so pinning it today keeps the output from changing on its own after a server upgrade.
Export large WordPress datasets without memory errors
The usual failure mode is loading too much into memory at once. PHP memory limits, max_execution_time, and reverse-proxy timeouts all kill long admin exports. Batch with WP_Query using posts_per_page and offset, or better, page with a stable cursor when offsets get expensive on large tables.
// $output is the stream opened as in the users example.
$offset = 0;
$limit = 500;
do {
$query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $limit,
'offset' => $offset,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
)
);
foreach ( $query->posts as $post_id ) {
fputcsv( $output, array( $post_id, get_the_title( $post_id ) ), ',', '"', '' );
}
$offset += $limit;
wp_cache_flush_runtime(); // WP 6.0+: clears only this request's in-memory cache
} while ( count( $query->posts ) === $limit );Why this helps:
fields => 'ids'keeps object hydration light- Disabling meta and term caches stops the object cache from growing without bound
- Fixed batch sizes keep peak memory closer to one page of rows
- Streaming with
fputcsv()avoids holding the full CSV string in RAM - Ordering by ascending
IDstops a post published mid-export from shifting pages and duplicating or skipping rows
Use wp_cache_flush_runtime(), not wp_cache_flush(), inside the loop. With a persistent object cache (Redis or Memcached), wp_cache_flush() empties the cache for the whole site in the middle of the export. The runtime function, available since WordPress 6.0, clears only the current request’s memory, and if the drop-in does not support it, it returns false without touching the shared cache.
On very large tables, offset itself becomes slow because MySQL still walks discarded rows. Prefer post__not_in carefully (it also scales poorly) or keyset pagination: WHERE ID > last_id ORDER BY ID ASC LIMIT N. For CLI jobs, that pattern is worth the extra code.
Official reference for query arguments: the WP_Query class on developer.wordpress.org.
Export WordPress posts to CSV with WP-CLI
When you export tens of thousands of rows, WP-CLI avoids the browser and the web server timeout chain.
wp post list --post_type=post --fields=ID,post_title,post_date --format=csv > posts.csvUseful for:
- large post or product catalogues
- WooCommerce order dumps that would timeout in wp-admin
- nightly cron exports into object storage or a shared drive
- migration and reporting hand-offs
Command docs: wp post list. It accepts --format=csv, json, yaml, ids, count, or table, plus any WP_Query argument as --<field>=<value>. One detail matters on big sites: --posts_per_page defaults to -1, which means every post in a single query. If the table is huge, page with --posts_per_page=1000 --paged=N in a shell loop.
Users have the matching command wp user list, whose default fields are ID, user_login, display_name, user_email, user_registered, and roles:
wp user list --role=customer --fields=ID,user_email,user_registered --format=csv > customers.csvEvery command accepts the global --ssh=user@host/path parameter, which runs the export against a remote server from your machine without a separate session.
You can also write a custom WP-CLI command that opens a file handle, pages through WC_Order_Query or WP_Query, and writes CSV to disk. That keeps memory predictable and lets you resume from a checkpoint if a job is interrupted:
WP_CLI::add_command( 'wppoland export-orders', function ( $args, $assoc_args ) {
$file = fopen( 'orders.csv', 'w' );
fputcsv( $file, array( 'order_id', 'date_created', 'status', 'total', 'billing_email' ), ',', '"', '' );
$page = 1;
do {
$orders = wc_get_orders(
array(
'type' => 'shop_order',
'limit' => 200,
'paged' => $page,
'orderby' => 'ID',
'order' => 'ASC',
)
);
foreach ( $orders as $order ) {
$created = $order->get_date_created();
fputcsv(
$file,
array(
$order->get_id(),
$created ? $created->date( 'c' ) : '',
$order->get_status(),
$order->get_total(),
$order->get_billing_email(),
),
',',
'"',
''
);
}
$page++;
} while ( count( $orders ) === 200 );
fclose( $file );
WP_CLI::success( 'Exported to orders.csv' );
} );limit and paged are documented parameters of wc_get_orders(). With limit => -1 the command would load every order into memory before writing the first row, which is exactly what batching avoids.
Export the full database with wp db export
Sometimes the request is not a spreadsheet but a copy of everything. wp db export runs mysqldump with the credentials from wp-config.php and writes an SQL file. --tables picks specific tables, --exclude_tables skips them, and - as the file name sends the dump to standard output:
wp db export - --tables=wp_posts,wp_postmeta | gzip > content.sql.gzAdjust the table prefix to your install. An SQL dump is for restoring or loading into another database, not for opening in Excel. It also contains the users table with password hashes and, often, API keys that plugins store in wp_options. Treat it as a secret: never in a public folder, never as an email attachment.
Export WordPress data to BI tools with the REST API
If the consumer is software, not a person with Excel, the REST API is often cleaner than generating CSV inside WordPress.
Typical cases:
- syncing content into a warehouse or BI tool
- feeding a CRM from users or leads
- headless front ends that need structured post payloads
Posts collection reference: Posts endpoint.
REST returns JSON. Page with page and per_page, authenticate with Application Passwords or OAuth-style flows your host supports, and respect rate limits. According to the pagination guide, per_page is capped at 100 records, and every response carries the X-WP-Total (total records) and X-WP-TotalPages (total pages) headers. The client should read X-WP-TotalPages and request pages up to that number instead of guessing when to stop. Build CSV downstream if a spreadsheet is still required. That split keeps WordPress responsible for authorised data access and leaves file formatting to the consumer.
Do not expose privileged export routes without authentication. Treat export endpoints like any other write-adjacent admin surface even when they only read.
Export WooCommerce products to CSV without a plugin
The catalogue needs no code. According to the product CSV importer and exporter documentation, WooCommerce includes its own tool for importing, exporting, and bulk-updating products:
- Go to Products > All Products and select Export at the top of the page.
- Choose the columns or keep the option to export all of them.
- Filter by product types and categories, or leave both at all.
- Tick the option to export all custom meta if you need data from WooCommerce or other plugins. Those columns get a
meta:prefix. - Select Generate CSV and wait for the browser to download the file.
The file uses the same format the importer reads, so it works as a base for bulk price or stock changes: export, edit in the sheet, import again. The tool covers the product catalogue. Orders are out of scope, and the next section deals with them.
How to export WooCommerce orders to CSV
Order exports look simple until line items, refunds, and HPOS change the picture.
Line items are not one row. An order can have several products, fees, shipping lines, and taxes. Decide up front whether one CSV row means one order or one line item. Mixing both without a clear schema produces unusable sheets.
Meta and custom fields explode width. Payment gateway IDs, tracking numbers, and plugin meta create wide, sparse columns. Export only the fields the report needs. Add a second file for line items if you must keep both shapes.
Storage backends differ. Classic shop_order posts and High-Performance Order Storage (HPOS) tables are not queried the same way. HPOS has been the default storage since WooCommerce 8.2, according to the wc_get_orders() documentation. Prefer WooCommerce CRUD (wc_get_orders() / WC_Order_Query) over raw WP_Query on shop_order so your exporter survives storage switches. To filter by date, date_created accepts a range in the form 2026-01-01...2026-03-31, read in the site timezone.
Status filters matter. Drafts, failed payments, refunded orders, and trash inflate or skew revenue reports. Document which statuses you include.
PII and access. Orders include names, emails, and addresses. Capability checks (manage_woocommerce or a custom capability), nonces, and audit logs belong in every admin exporter. Prefer WP-CLI for bulk dumps so the file never sits in a publicly reachable uploads path.
Performance. Joining every order meta key in one SQL statement looks fast until the table grows. Page orders, hydrate each with wc_get_order(), write the row, then discard the object. That pattern is slower per order than a giant JOIN and far more stable under memory limits.
Currency and formatting. Store totals as plain numbers. Let Excel format currency. Locale-specific thousand separators inside CSV fields break imports into other tools.
Date fields. Store ISO-8601 or Unix timestamps in the CSV and format in the spreadsheet. Mixed local date strings (03/04/2026 vs 04/03/2026) cause silent reorder bugs when the file crosses regions.
Refunds and partial refunds. Decide whether refunded totals appear as negative rows, separate refund IDs, or netted order totals. Document the choice in a README next to the export cron so finance and engineering agree on the same sheet.
How to prevent formula injection in an exported CSV
A CSV exported from WordPress mixes in data your users typed: display names, comments, form fields, billing addresses. OWASP describes the risk as CSV injection, also known as formula injection: it occurs when a website embeds untrusted input inside a CSV file. If a cell starts with =, +, -, or @, or with a tab, carriage return, or line feed, the spreadsheet may treat it as a formula when the file opens.
OWASP’s mitigation is to make sure no cell begins with those characters, for example by prepending a single quote so the program reads the cell as text:
function wppoland_csv_safe( $value ) {
$value = (string) $value;
if ( '' !== $value && in_array( $value[0], array( '=', '+', '-', '@', "\t", "\r", "\n" ), true ) ) {
return "'" . $value;
}
return $value;
}Apply it to free-text columns, not to amounts WooCommerce calculates: a negative refund total starts with - and would be turned into text, which breaks sums in the sheet.
Export a user’s personal data with the GDPR tool
When the request comes from a person exercising their rights, you do not need a custom exporter. The plugin developer documentation explains that WordPress 4.9.6 added a Personal Data Export tool (Tools > Export Personal Data) aimed at laws such as the GDPR.
The key for every export is the email address, which covers registered users and also someone who only left a comment. The admin enters the username or email, WordPress sends a confirmation link, and once it is confirmed the admin downloads the ZIP or emails it straight to the person. Inside is an index HTML page with the data organised in groups, such as comments. Plugins that store their own personal data hook in through the wp_privacy_personal_data_exporters filter so their data lands in the same file.
WordPress CSV export checklist
- Prefer CSV over XLSX inside WordPress, and WXR only for moving content.
- Stream with
fputcsv(), an explicitescape, and capability checks for small admin downloads. - Batch with
WP_Queryor WooCommerce queries; disable unnecessary caches. - Move large or scheduled jobs to WP-CLI.
- Use the REST API when another system pulls structured data.
- For WooCommerce, define row grain (order vs line item) and use CRUD APIs so HPOS does not break the exporter.
- Neutralise cells that start with
=,+,-, or@in free-text columns.
Done well, WordPress exports stay boring: predictable columns, bounded memory, and no surprise timeouts during a Black Friday report request.







