How to import a CSV with validation and feedback in PHP in WordPress

Contents

Introduction

This tutorial shows a complete, secure, production-ready approach to importing CSV files into WordPress using PHP with validation and clear feedback for the user. It covers: preparing the CSV format, building an admin upload UI, securely handling the upload, validating headers and each row (required fields, types, formats, uniqueness), inserting or updating WordPress data (posts, post meta, users or a custom table), and presenting an error report with a downloadable CSV of failed rows. The examples are implementation-ready and intentionally conservative on security and performance.

Assumptions and prerequisites

  • WordPress 5.x or later.
  • Access to add a simple plugin file or paste code into a themes functions.php while developing. Prefer a small plugin for production.
  • CSV files are UTF-8 encoded, comma-separated, and with a header row. If your CSV uses different delimiters, adapt fgetcsv settings accordingly.
  • Basic PHP and WordPress development knowledge (hooks, capabilities, sanitization functions).

Design decisions

  • Upload handled from wp-admin only (admin menu page). Restrict to users with a capability check (example: manage_options or edit_posts).
  • Use nonces to protect from CSRF.
  • Use wp_handle_upload for safe file placement and WordPress file-type checks.
  • Validate header row exactly (expected columns) and validate per-row rules (required, datatype, uniqueness). Collect granular errors per row to report back.
  • Do not perform unbounded memory usage: for very large CSVs you must implement chunking. This tutorial includes notes and a simple chunking approach for large files.

CSV format example

Here is a sample CSV expected by the example code below. The CSV will create/update posts of a given custom post type (or default posts). Required header names are: title, content, author_email, published_date, category, meta_key_example

Example CSV content (UTF-8, comma separated):

title,content,author_email,published_date,category,meta_key_example
Hello world,This is content,author@example.com,2025-09-01,News,some-meta
Second post,Body of second,author2@example.com,2025-09-15,Blog,another-meta

High-level steps

  1. Create an admin menu page with a secure form and nonce.
  2. Upload file via wp_handle_upload and validate filetype/size/mime.
  3. Open the CSV with fopen/fgetcsv and read header row.
  4. Validate header against expected columns and validate rows one-by-one with rules.
  5. Insert or update records in WordPress using sanitized values and capability checks.
  6. Collect per-row errors, counts, and present a summary to the user along with a downloadable CSV of failed rows for correction.

Complete example plugin

Below is a compact but complete plugin-style implementation. Put this code in a file such as wp-content/plugins/csv-importer-with-validation/csv-importer.php and activate the plugin. It is intentionally explicit about validation and feedback and uses posts as the data target. Adjust post type and meta keys to your needs.

 false))
            if (!empty(uploaded[error])) {
                results[error] = Upload error:  . esc_html(uploaded[error])
            } else {
                file = uploaded[file] // full server path
                results = csv_importer_process_file(file)
                // remove the temp uploaded file after processing
                @unlink(file)
            }
        } else {
            results[error] = No file uploaded.
        }
    }

    // Render form
    ?>
    

CSV Importer


Results

Error:

Inserted: , Updated: , Failed:

Download the CSV of failed rows: >failed_rows.csv

Sample error rows (first 10)

10) break ?>
RowError
File not readable.) } // Limit file size to 10MB for example (adjust as needed) if (filesize(file_path) > 10 1024 1024) { return array(error => File too large. Max 10MB.) } // Expected headers expected_headers = array(title,content,author_email,published_date,category,meta_key_example) // Open file handle = fopen(file_path, r) if (!handle) { return array(error => Cannot open uploaded file.) } // Read header header = fgetcsv(handle) if (header === false) { fclose(handle) return array(error => CSV appears empty or unreadable.) } // Normalize header (trim and lowercase) normalized = array_map(function(h){ return trim(strtolower(h)) }, header) // Validate headers - require exact set (order-insensitive) missing = array_diff(expected_headers, normalized) if (!empty(missing)) { fclose(handle) return array(error => Missing expected columns: . implode(, , missing)) } // Map normalized header to original column indexes cols = array() foreach (normalized as i => name) { cols[name] = i } // Prepare results inserted = 0 updated = 0 errors = array() failed_rows_csv = array() row_number = 1 // header is row 1 // Optionally improve performance for bulk operations if (function_exists(wp_suspend_cache_invalidation)) { wp_suspend_cache_invalidation(true) } if (function_exists(wp_defer_term_counting)) { wp_defer_term_counting(true) } // Increase time limit for long imports (cautious) @set_time_limit(0) // Loop rows while ((data = fgetcsv(handle)) !== false) { row_number // Skip empty rows if (count(array_filter(data, strlen)) === 0) { continue } // Build associative row using expected headers row = array() foreach (expected_headers as col_name) { index = cols[col_name] row[col_name] = isset(data[index]) ? data[index] : } // Validate row validation = csv_importer_validate_row(row, row_number) if (validation !== true) { // store as failed errors[] = array(row => row_number, message => validation, raw => row) // Prepare CSV export row (append error message as last column) failed_row = row failed_row[error] = validation failed_rows_csv[] = failed_row continue } // Ready to insert/update post post_title = sanitize_text_field(row[title]) post_content = wp_kses_post(row[content]) author_email = sanitize_email(row[author_email]) published_date = sanitize_text_field(row[published_date]) category = sanitize_text_field(row[category]) meta_example = sanitize_text_field(row[meta_key_example]) // Find author by email or fallback to current user author_id = 0 if (!empty(author_email)) { user = get_user_by(email, author_email) if (user) { author_id = user->ID } else { // if author email not found, use current user author_id = get_current_user_id() } } else { author_id = get_current_user_id() } // Check for existing post with same title and date (example uniqueness rule) existing = get_posts(array( title => post_title, post_type => post, post_status => array(publish,draft,pending,future), numberposts => 1, suppress_filters => true, )) if (!empty(existing)) { // Update the first found result - adapt uniqueness query to your needs post_id = existing[0]->ID update_post = array( ID => post_id, post_title => wp_slash(post_title), post_content => wp_slash(post_content), post_author => author_id, post_date => date(Y-m-d H:i:s, strtotime(published_date)), ) res = wp_update_post(update_post, true) if (is_wp_error(res)) { errors[] = array(row => row_number, message => Failed to update post: . res->get_error_message(), raw => row) failed_row = row failed_row[error] = Failed to update post: . res->get_error_message() failed_rows_csv[] = failed_row } else { // Update meta update_post_meta(post_id, meta_key_example, meta_example) updated } } else { // Insert new post new_post = array( post_title => wp_slash(post_title), post_content => wp_slash(post_content), post_status => publish, post_author => author_id, post_type => post, post_date => date(Y-m-d H:i:s, strtotime(published_date)), ) res = wp_insert_post(new_post, true) if (is_wp_error(res)) { errors[] = array(row => row_number, message => Failed to insert post: . res->get_error_message(), raw => row) failed_row = row failed_row[error] = Failed to insert post: . res->get_error_message() failed_rows_csv[] = failed_row } else { update_post_meta(res, meta_key_example, meta_example) // assign category: ensure term exists, then set if (!empty(category)) { term = term_exists(category, category) if (term === 0 term === null) { term_result = wp_insert_term(category, category) if (!is_wp_error(term_result) !empty(term_result[term_id])) { wp_set_post_terms(res, array(term_result[term_id]), category, false) } } else { term_id = is_array(term) ? term[term_id] : term wp_set_post_terms(res, array(term_id), category, false) } } inserted } } } // end while fclose(handle) // Restore state if (function_exists(wp_defer_term_counting)) { wp_defer_term_counting(false) } if (function_exists(wp_suspend_cache_invalidation)) { wp_suspend_cache_invalidation(false) } // Prepare failed rows CSV for download if any errors_csv_url = if (!empty(failed_rows_csv)) { // Create CSV content in uploads upload_dir = wp_upload_dir() filename = csv-import-failed- . time() . .csv filepath = trailingslashit(upload_dir[basedir]) . filename fp = fopen(filepath, w) if (fp) { // write header head = array_merge(expected_headers, array(error)) fputcsv(fp, head) foreach (failed_rows_csv as row) { line = array() foreach (expected_headers as c) { line[] = isset(row[c]) ? row[c] : } line[] = isset(row[error]) ? row[error] : fputcsv(fp, line) } fclose(fp) errors_csv_url = trailingslashit(upload_dir[baseurl]) . filename } } return array( inserted => inserted, updated => updated, errors => errors, errors_csv_url => errors_csv_url, ) } / Validate a single row associative array. Return true if valid, or a string with an error message. Rules used here: - title: required, max 200 chars - content: required - author_email: valid email if provided - published_date: valid date (Y-m-d or recognized by strtotime) - category: optional, max 100 chars - meta_key_example: optional / function csv_importer_validate_row(row, row_number = 0) { // title required if (empty(row[title]) trim(row[title]) === ) { return Missing required field: title } if (mb_strlen(row[title]) > 200) { return Title too long (max 200 chars) } // content required if (empty(row[content]) trim(row[content]) === ) { return Missing required field: content } // author_email optional but if present must be valid if (!empty(row[author_email]) !is_email(row[author_email])) { return Invalid author_email: . row[author_email] } // published_date optional but if present must be a valid date if (!empty(row[published_date])) { ts = strtotime(row[published_date]) if (ts === false) { return Invalid published_date: . row[published_date] } } // category length check if (!empty(row[category]) mb_strlen(row[category]) > 100) { return Category value too long (max 100 chars) } // Add any custom uniqueness checks or business rules here. // Example: do not allow duplicate title date in the CSV itself -- could implement tracking. return true } ?>

How the example works (explanation)

The plugin creates a CSV Importer page under Tools. The form includes a nonce and accepts a CSV file. On submit, the uploaded file is passed through wp_handle_upload for safe temporary storage. The code opens the file with fopen and fgetcsv, normalizes and validates the header row, then iterates each CSV row performing per-field validations in csv_importer_validate_row(). Valid rows are inserted or updated as WordPress posts failed rows are collected and written to a downloadable CSV with an added error column explaining the issue.

Security considerations

  • Always verify capabilities with current_user_can() and use nonces to protect the form.
  • Use wp_handle_upload to avoid path-traversal attacks and to leverage WordPress checks.
  • Validate file size and MIME type. Example restrict to .csv and text/csv.
  • Sanitize all input: sanitize_text_field, sanitize_email, wp_kses_post (for HTML content), and never directly insert raw CSV values into SQL or output without escaping.
  • Prefer to insert as posts or use WordPress APIs rather than custom direct SQL unless necessary. If using wpdb, use prepared statements.
  • Remove temporary uploaded files once processed.

Performance tips for large CSVs

  • Process in chunks: read N rows, process and free memory, then continue. You can implement this by tracking file offset and re-opening the file, or by using background cron or WP-CLI to process large files.
  • Defer term counting and cache invalidation during bulk insertions: wp_defer_term_counting(true), wp_suspend_cache_invalidation(true) and restore them after processing.
  • Use transactions if inserting into custom tables and your storage supports it.
  • Consider using WP-CLI for very large imports (no HTTP timeouts, CLI memory/time limits can be adjusted safely).
  • Allow the user to map CSV headers to expected fields in a two-step upload when CSVs vary in format.

Error reporting and downloadable error CSV

Collecting row-level errors is critical. The example writes failed rows to an errors CSV that is stored in the uploads folder and a URL is presented to the admin. Ensure you periodically delete old error files or save them to a protected location if they contain sensitive data. Optionally, you can return detailed JSON errors for AJAX-based imports.

Adapting to other targets

  • Users: Use wp_create_user / wp_update_user and validate fields like user_login and user_email, check for existing users with email or login before creating.
  • Custom post types: change post_type in wp_insert_post and manage custom meta and taxonomies.
  • Custom database tables: use wpdb->insert with wpdb->prepare for safe queries. Always validate and escape.

Additional improvements you may want

  • Interactive mapping UI: let admins map CSV columns to fields (handles different CSV header names).
  • Dry-run mode: validate everything and show a preview without inserting anything.
  • AJAX progress or background processing with transient state, especially for long imports.
  • Duplicate detection within the CSV itself (track titles seen during the run) to avoid inserting duplicates.
  • Configurable validation rules per field (regex, allowed values, external API checks).
  • Logging: record import runs with user, time, counts, and link to error reports for auditability.

Small notes and gotchas

  • CSV encoding: ensure CSVs are UTF-8. If uploaded as Windows-1252 or other encodings youll need to convert or use mb_detect_encoding mb_convert_encoding.
  • Line endings: fgetcsv handles common line endings, but confirm on files exported by Excel/Sheets.
  • Quote and delimiter differences: if your CSV uses semicolons or other separators, pass the correct delimiter parameter to fgetcsv().
  • Empty rows and stray whitespace: trim header names and use array_filter on rows to skip fully-empty records.

References and helpful functions

  • wp_handle_upload()
  • wp_verify_nonce(), wp_nonce_field()
  • current_user_can()
  • fgetcsv(), fopen()
  • get_user_by(), wp_insert_post(), wp_update_post(), update_post_meta()
  • wp_defer_term_counting(), wp_suspend_cache_invalidation()
  • wp_upload_dir()

Closing

This article provided a full working example for importing CSVs into WordPress with thorough validation and user-friendly feedback, plus guidance on security, performance, and common improvements. Use the example code as a solid starting point and adapt rules, target data types, and error handling to your applications needs.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *