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.
Choose the right export method
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.
CSV vs XLSX
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. Write the BOM before the header row when your audience lives in Excel.
Export CSV with PHP
For custom admin tools, stream CSV to the browser. Capability checks come first. Then set headers, open php://output, write the header row, and stream data rows.
function wppoland_export_users_csv() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Access denied.' );
}
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' ) );
$users = get_users(
array(
'fields' => array( 'ID', 'user_email', 'user_registered' ),
'number' => 500,
)
);
foreach ( $users as $user ) {
fputcsv(
$output,
array(
$user->ID,
$user->user_email,
$user->user_registered,
)
);
}
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.
Protect the endpoint with a nonce and a capability check. 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.
Batch with WP_Query and avoid memory limits
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.
$offset = 0;
$limit = 500;
do {
$query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $limit,
'offset' => $offset,
'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(); // optional on very long loops in CLI/admin tools
} while ( ! empty( $query->posts ) );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
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.
Use WP-CLI for heavy exports
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.
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.
Use the REST API when another system needs the data
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. 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.
WooCommerce orders export pitfalls
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. Prefer WooCommerce CRUD (wc_get_orders() / WC_Order_Query) over raw WP_Query on shop_order so your exporter survives storage switches.
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.
Putting it together
- Prefer CSV over XLSX inside WordPress.
- Stream with
fputcsv()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.
Done well, WordPress exports stay boring: predictable columns, bounded memory, and no surprise timeouts during a Black Friday report request.







