Clients often want their custom post type data in a spreadsheet: every book, event or location, with its categories and custom fields. The built-in Tools → Export only gives you WXR XML. Export plugins are usually larger than the job needs, and many build the file in memory, so they struggle on big sites.
This snippet adds an Export CSV button to the list screen of every post type (posts, pages and custom post types). One click downloads a UTF-8 CSV with the core fields, one column per taxonomy and any meta keys you choose. The download is protected by a nonce and a capability check. Cells that a spreadsheet would run as formulas are neutralised.
The snippet
Save it as wp-content/mu-plugins/dpc-export-csv.php, or paste it (without the header) into a small plugin or your theme’s functions.php.
<?php
/**
* Plugin Name: DPC Export Post Type to CSV
* Description: Adds an "Export CSV" button to every post type list screen.
*/
defined( 'ABSPATH' ) || exit;
add_action( 'manage_posts_extra_tablenav', 'dpc_csv_export_button' );
add_action( 'admin_post_dpc_export_csv', 'dpc_csv_export_download' );
/**
* Print the button above the list table.
*/
function dpc_csv_export_button( string $which ): void {
$screen = get_current_screen();
if ( 'top' !== $which || ! $screen || ! post_type_exists( $screen->post_type ) ) {
return;
}
$object = get_post_type_object( $screen->post_type );
if ( ! current_user_can( $object->cap->edit_others_posts ) ) {
return;
}
$url = wp_nonce_url(
add_query_arg(
array(
'action' => 'dpc_export_csv',
'post_type' => $screen->post_type,
),
admin_url( 'admin-post.php' )
),
'dpc_export_csv_' . $screen->post_type
);
printf(
'<div class="alignleft actions"><a href="%s" class="button">%s</a></div>',
esc_url( $url ),
esc_html__( 'Export CSV', 'dpc' )
);
}
/**
* Handle the download request.
*/
function dpc_csv_export_download(): void {
$post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : '';
$object = get_post_type_object( $post_type );
if ( ! $object || ! $object->show_ui ) {
wp_die( esc_html__( 'Unknown post type.', 'dpc' ), '', array( 'response' => 400 ) );
}
check_admin_referer( 'dpc_export_csv_' . $post_type );
if ( ! current_user_can( $object->cap->edit_others_posts ) ) {
wp_die( esc_html__( 'You are not allowed to export this content.', 'dpc' ), '', array( 'response' => 403 ) );
}
$filename = sanitize_file_name( $post_type . '-' . gmdate( 'Y-m-d' ) . '.csv' );
nocache_headers();
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
$out = fopen( 'php://output', 'w' );
fwrite( $out, "\xEF\xBB\xBF" ); // BOM so Excel opens the file as UTF-8.
dpc_csv_export_write( $post_type, $out );
fclose( $out );
exit;
}
/**
* Taxonomies that get their own column.
*/
function dpc_csv_export_taxonomies( string $post_type ): array {
$taxonomies = get_object_taxonomies( $post_type, 'objects' );
$taxonomies = array_filter( $taxonomies, static fn( WP_Taxonomy $tax ): bool => $tax->show_ui );
return array_keys( $taxonomies );
}
/**
* Meta keys that get their own column. Empty unless you add some.
*/
function dpc_csv_export_meta_keys( string $post_type ): array {
return array_values( (array) apply_filters( 'dpc_csv_export_meta_keys', array(), $post_type ) );
}
/**
* Turn a value into a safe CSV cell.
*/
function dpc_csv_export_cell( mixed $value ): string {
$value = is_scalar( $value ) || null === $value ? (string) $value : (string) wp_json_encode( $value );
// Stop spreadsheet apps from running the cell as a formula.
if ( '' !== $value && in_array( $value[0], array( '=', '+', '-', '@', "\t", "\r" ), true ) ) {
$value = "'" . $value;
}
return $value;
}
/**
* Write all posts of a type to an open stream. Returns the number of rows.
*
* @param resource $handle
*/
function dpc_csv_export_write( string $post_type, $handle ): int {
$taxonomies = dpc_csv_export_taxonomies( $post_type );
$meta_keys = dpc_csv_export_meta_keys( $post_type );
$batch = 200;
$page = 1;
$count = 0;
$header = array_merge(
array( 'ID', 'Title', 'Slug', 'Status', 'Date', 'Modified', 'Author', 'URL', 'Excerpt' ),
$taxonomies,
$meta_keys
);
fputcsv( $handle, $header, ',', '"', '' );
do {
$query = new WP_Query(
array(
'post_type' => $post_type,
'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ),
'posts_per_page' => $batch,
'paged' => $page,
'orderby' => 'ID',
'order' => 'ASC',
'no_found_rows' => true,
)
);
foreach ( $query->posts as $post ) {
$row = array(
$post->ID,
$post->post_title,
$post->post_name,
$post->post_status,
$post->post_date,
$post->post_modified,
get_the_author_meta( 'display_name', (int) $post->post_author ),
get_permalink( $post ),
$post->post_excerpt,
);
foreach ( $taxonomies as $taxonomy ) {
$terms = get_the_terms( $post, $taxonomy );
$row[] = is_array( $terms ) ? implode( '|', wp_list_pluck( $terms, 'name' ) ) : '';
}
foreach ( $meta_keys as $key ) {
$row[] = get_post_meta( $post->ID, $key, true );
}
fputcsv( $handle, array_map( 'dpc_csv_export_cell', $row ), ',', '"', '' );
++$count;
}
++$page;
} while ( count( $query->posts ) === $batch );
return $count;
}
How it works
manage_posts_extra_tablenavfires above the list table on every post type screen, including pages, even though the hook name says posts. The button only appears for users who canedit_others_postsfor that type, because the export includes other people’s drafts and private posts.- The link points to
admin-post.phpwith a nonce tied to the post type.dpc_csv_export_download()sanitises the post type, then verifies the nonce withcheck_admin_referer()and checks the capability again, because the button’s visibility alone is not access control. dpc_csv_export_write()streams straight tophp://output, 200 posts per query, so you never hold the whole file in memory. Posts in the trash and auto-drafts are skipped.- Each taxonomy that has an admin UI gets one column, with term names joined by
|. dpc_csv_export_cell()puts a'in front of any value that starts with=,+,-,@, a tab or a carriage return. Without it, a post titled=HYPERLINK(...)would run as a formula when the client opens the file in Excel (CSV injection).
Customise it
Custom fields aren’t exported by default, because post meta is full of internal keys. List the ones you want for each post type:
add_filter( 'dpc_csv_export_meta_keys', function ( array $keys, string $post_type ): array {
if ( 'book' === $post_type ) {
$keys = array_merge( $keys, array( 'isbn', 'price' ) );
}
return $keys;
}, 10, 2 );
- Need the content too? Add
'Content'to the header array and$post->post_contentto$row. Expect large cells full of block markup. - European Excel often expects semicolons. Change the
','delimiter in bothfputcsv()calls to';'. - Only some post types: return early in
dpc_csv_export_button()unless$screen->post_typeis in your own allow-list, and make the same check in the download handler. - ACF or Meta Box fields that store arrays are written as JSON. Format them yourself in the loop if you need readable values.
Gotchas
- The export ignores the list screen’s current filters and search, and always exports every non-trashed post of that type.
- Values that legitimately start with
-or+, such as negative numbers or phone numbers, also get the'prefix. Excel hides it, but other tools may not. - On very large sites the request can still hit
max_execution_time. For tens of thousands of rows, rundpc_csv_export_write()from WP-CLI or a cron job and write to a file instead ofphp://output.
Still setting up the post type? See Attach Category to Custom Post type. To show the same taxonomy data on the list screen itself, use Add Taxonomy to Admin columns.