Back to Tips & Tricks

JetEngine: Import CSV Data into Custom Content Types (CCTs)

Atif JanPublished on Updated
Abstract teal illustration of spreadsheet rows flowing into a database table, representing a JetEngine CSV to CCT import

JetEngine's Skins Manager moves a Custom Content Type's settings and fields between sites, but not the items stored in it. To import CSV rows into a CCT, you have two reliable routes. The first is WP Ultimate CSV Importer Pro, which Crocoblock documents for CCT imports. The second is a short PHP script that reads the CSV and saves each row through JetEngine's CCT item handler, update_item().

This guide covers both. It includes a secure upload form that imports customer names and emails into a cliente_cct (Customers) CCT and updates existing customers instead of creating duplicates.

How CCTs store data

CCTs don't use the WordPress posts table. Each CCT gets its own SQL table, and each item is one row in that table. JetEngine builds the table name from your database prefix plus jet_cct_ plus the CCT slug. With the default wp_ prefix, the slug cliente_cct becomes wp_jet_cct_cliente_cct. The only way to change the table name is to change the slug.

Crocoblock recommends CCTs when you will add a big number of items, because the lean table keeps the site fast. Know the trade-offs before you import thousands of rows:

  • CCT items have no single pages by default. You can only display them in a Listing Grid unless you link the CCT to a post type with Has Single Page.
  • CCTs have no taxonomies of their own. To categorize items, create a relation between the CCT and a taxonomy.

Choose a CCT because it is faster and leaner for large datasets, not because it is more flexible than a custom post type.

Before you import: set up the CCT and the CSV

  1. Turn on the module. Go to JetEngine > JetEngine > Modules > Default Modules and switch on Custom Content Types. After the page reloads, a Custom Content Types tab appears in the admin.
  2. Create the CCT. Go to JetEngine > Custom Content Types > Add New. In General Settings, enter the name "Customers". The Slug fills in from the name. Set it to cliente_cct. Slugs allow Latin letters only and no spaces, so use hyphens or underscores.
  3. Add the fields. In the Fields block, add two text fields with the Name/ID values name and email. The Name/ID is the column name stored in the database. It accepts lowercase Latin letters, numbers, hyphens and underscores. You can also switch on Is required and set a Default value or a Characters limit.
  4. Set the admin view. Use the Admin Columns block to show name and email in the item list. That makes it easy to check the import later. If you store file URLs, see our tip on making media file URLs clickable in CCT admin columns.
  5. Prepare the CSV. The first row must contain headers that match the field Name/IDs exactly. Save the file as UTF-8 CSV.
name,email
Ana Lopez,ana@example.com
Marco Rossi,marco@example.com

Option 1: Import with WP Ultimate CSV Importer Pro

This is the route Crocoblock documents in How to Import and Export Custom Content Type. Use it if you would rather not write code.

  1. Install and activate WP Ultimate CSV Importer Pro.
  2. Open the WP Ultimate CSV Importer menu and go to the Import/Export tab.
  3. Click Upload from Desktop and choose your CSV file.
  4. Select your CCT (Customers) from the dropdown.
  5. Match each CSV column to the matching CCT field, then click Next and finish the import.

When the import is done, open the Customers CCT in the admin. You should see one item per CSV row. Smackcoders, the plugin's developer, has a detailed walkthrough in its JetEngine CSV import guide. WP All Import's JetEngine import add-on also imports into CCTs, including relations and repeaters.

Option 2: PHP upload form using the CCT API

Write your own importer if you want custom validation, a private import page for a client, or one plugin fewer. Do not insert rows directly into wp_jet_cct_cliente_cct with $wpdb. Crocoblock publishes a CCT CRUD API for this job. Saving through the item handler keeps JetEngine's create and update hooks firing, and it avoids hard-coding a table prefix that differs between sites.

The snippet below adds a [bmc_cct_csv_import] shortcode. It:

  • shows the form only to logged-in users with the manage_options capability
  • checks a nonce, the file extension, the file size and that the file really was uploaded
  • maps columns by header name, so column order doesn't matter
  • sanitizes every value and validates each email
  • updates an existing customer when the email already exists, and creates a new item otherwise
  • reports how many rows were created, updated and skipped, with a reason for each skipped line

Change the slug in bmc_cct_import_config(). If your CCT fields are not called name and email, rename them everywhere they appear in the code: the two array_search() calls, the field check before the loop, the $item keys and the 'field' => 'email' lookup. Add the code to the end of your child theme's functions.php file or to a code snippets plugin, without the opening <?php line (functions.php already has one). Then put [bmc_cct_csv_import] on a private, non-indexed page:

Code

<?php
/**
 * BoostMyCroco: import a CSV file into a JetEngine Custom Content Type (CCT).
 *
 * Usage: place [bmc_cct_csv_import] on a private page.
 * Only logged-in users with the capability below see and can use the form.
 */

function bmc_cct_import_config() {
    return array(
        'slug'       => 'cliente_cct',    // CCT slug.
        'capability' => 'manage_options', // Who may import.
        'max_bytes'  => 2 * MB_IN_BYTES,  // Max upload size.
        'max_rows'   => 2000,             // Max data rows per upload.
        'delimiter'  => ',',              // Use ';' for semicolon-separated files.
    );
}

add_shortcode( 'bmc_cct_csv_import', 'bmc_cct_csv_import_shortcode' );

function bmc_cct_csv_import_shortcode() {
    $config = bmc_cct_import_config();

    if ( ! is_user_logged_in() || ! current_user_can( $config['capability'] ) ) {
        return '';
    }

    $report = '';
    if ( isset( $_POST['bmc_cct_import_submit'] ) ) {
        $report = bmc_cct_csv_handle_upload( $config );
    }

    ob_start();
    echo $report; // Already escaped in bmc_cct_notice().
    ?>
    <form method="post" enctype="multipart/form-data">
        <?php wp_nonce_field( 'bmc_cct_csv_import', 'bmc_cct_import_nonce' ); ?>
        <p>
            <label for="bmc_cct_csv">CSV file (columns: name, email)</label><br>
            <input type="file" id="bmc_cct_csv" name="bmc_cct_csv" accept=".csv,text/csv" required>
        </p>
        <p><button type="submit" name="bmc_cct_import_submit" value="1">Import CSV</button></p>
    </form>
    <?php
    return ob_get_clean();
}

function bmc_cct_notice( $message, $details = array() ) {
    $html = '<div class="bmc-cct-import-report"><p>' . esc_html( $message ) . '</p>';
    if ( ! empty( $details ) ) {
        $html .= '<ul>';
        foreach ( $details as $detail ) {
            $html .= '<li>' . esc_html( $detail ) . '</li>';
        }
        $html .= '</ul>';
    }
    return $html . '</div>';
}

function bmc_cct_clean_header( $value ) {
    $value = str_replace( "\xEF\xBB\xBF", '', (string) $value ); // Remove UTF-8 BOM added by Excel.
    return strtolower( trim( $value ) );
}

function bmc_cct_csv_handle_upload( $config ) {
    // 1. Nonce check.
    $nonce = isset( $_POST['bmc_cct_import_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bmc_cct_import_nonce'] ) ) : '';
    if ( ! wp_verify_nonce( $nonce, 'bmc_cct_csv_import' ) ) {
        return bmc_cct_notice( 'Security check failed. Reload the page and try again.' );
    }

    // 2. JetEngine and the CCT module must be active.
    if ( ! function_exists( 'jet_engine' ) || ! jet_engine()->modules->is_module_active( 'custom-content-types' ) ) {
        return bmc_cct_notice( 'JetEngine or its Custom Content Types module is not active.' );
    }

    // CCTs are registered on init (priority 12). A shortcode renders much later, so this is safe.
    $type = \Jet_Engine\Modules\Custom_Content_Types\Module::instance()->manager->get_content_types( $config['slug'] );
    if ( ! $type ) {
        return bmc_cct_notice( 'CCT not found. Check the slug in bmc_cct_import_config().' );
    }

    // 3. File checks.
    if ( empty( $_FILES['bmc_cct_csv'] ) || UPLOAD_ERR_OK !== (int) $_FILES['bmc_cct_csv']['error'] ) {
        return bmc_cct_notice( 'No file uploaded, or the upload failed.' );
    }

    $file = $_FILES['bmc_cct_csv'];

    if ( (int) $file['size'] > $config['max_bytes'] ) {
        return bmc_cct_notice( 'The file is too large.' );
    }

    $filetype = wp_check_filetype( $file['name'], array( 'csv' => 'text/csv' ) );
    if ( 'csv' !== $filetype['ext'] || ! is_uploaded_file( $file['tmp_name'] ) ) {
        return bmc_cct_notice( 'Please upload a .csv file.' );
    }

    $handle = fopen( $file['tmp_name'], 'r' );
    if ( ! $handle ) {
        return bmc_cct_notice( 'Could not read the file.' );
    }

    // 4. Read the header row and find the columns by name.
    $delimiter = $config['delimiter'];
    $header    = fgetcsv( $handle, 0, $delimiter, '"', '\\' );
    $header    = is_array( $header ) ? array_map( 'bmc_cct_clean_header', $header ) : array();
    $name_col  = array_search( 'name', $header, true );
    $email_col = array_search( 'email', $header, true );

    if ( false === $name_col || false === $email_col ) {
        fclose( $handle );
        return bmc_cct_notice( 'The first row must contain the column names: name, email.' );
    }

    // Stop if the CCT is missing a field. An unknown field is silently dropped from the
    // lookup query, which would then match (and overwrite) the newest item.
    $fields = $type->get_formatted_fields();
    if ( ! isset( $fields['name'], $fields['email'] ) ) {
        fclose( $handle );
        return bmc_cct_notice( 'The CCT needs fields with the Name/IDs "name" and "email".' );
    }

    $handler = $type->get_item_handler();
    $type->db->set_format_flag( ARRAY_A ); // Query results as associative arrays.

    $created = 0;
    $updated = 0;
    $skipped = 0;
    $rows    = 0;
    $line    = 1;
    $notes   = array();

    // 5. Validate and save each row.
    while ( false !== ( $row = fgetcsv( $handle, 0, $delimiter, '"', '\\' ) ) ) {
        $line++;

        if ( array( null ) === $row ) {
            continue; // Blank line.
        }

        if ( ++$rows > $config['max_rows'] ) {
            $notes[] = sprintf( 'Stopped at line %d: limit of %d rows reached.', $line, $config['max_rows'] );
            break;
        }

        $name  = sanitize_text_field( isset( $row[ $name_col ] ) ? $row[ $name_col ] : '' );
        $email = sanitize_email( isset( $row[ $email_col ] ) ? $row[ $email_col ] : '' );

        if ( '' === $name ) {
            $skipped++;
            $notes[] = sprintf( 'Line %d skipped: name is empty.', $line );
            continue;
        }

        if ( ! is_email( $email ) ) {
            $skipped++;
            $notes[] = sprintf( 'Line %d skipped: invalid email.', $line );
            continue;
        }

        // Keys must match the CCT field Name/IDs.
        $item = array(
            'name'  => $name,
            'email' => $email,
        );

        // Upsert: update the existing item with this email instead of creating a duplicate.
        $args = $type->prepare_query_args(
            array(
                array(
                    'field'    => 'email',
                    'operator' => '=',
                    'value'    => $email,
                ),
            )
        );
        $existing = $type->db->query( $args, 1, 0, array(), 'AND' );

        if ( ! empty( $existing ) ) {
            $first = reset( $existing );
            if ( ! empty( $first['_ID'] ) ) {
                $item['_ID'] = absint( $first['_ID'] );
            }
        }

        // No _ID = create a new item. With _ID = update that item.
        $result = $handler->update_item( $item );

        if ( ! $result || is_wp_error( $result ) ) {
            $skipped++;
            $notes[] = sprintf(
                'Line %d skipped: could not be saved%s.',
                $line,
                is_wp_error( $result ) ? ' (' . $result->get_error_message() . ')' : ''
            );
        } elseif ( isset( $item['_ID'] ) ) {
            $updated++;
        } else {
            $created++;
        }
    }

    fclose( $handle );

    return bmc_cct_notice(
        sprintf( 'Import finished: %d created, %d updated, %d skipped.', $created, $updated, $skipped ),
        $notes
    );
}

How the code works

  • Timing. CCTs are registered on init at priority 12, and get_content_types() returns nothing before that. A shortcode runs while the page renders, well after init. If you move the import into your own hook, use init at priority 12 or higher, or a later action.
  • Module guard. jet_engine()->modules->is_module_active( 'custom-content-types' ) stops the import with a clear message when the module is off.
  • Item handler. $type->get_item_handler() returns the object that saves items. update_item() takes an associative array of CCT fields. Without _ID it creates a new item. With _ID it updates that item.
  • Duplicate check. prepare_query_args() builds a query for email = value, and $type->db->query() runs it. set_format_flag( ARRAY_A ) makes the results associative arrays, so $first['_ID'] works. Duplicates inside the same file are caught too, because each row is saved before the next lookup.
  • Headers. Header names are trimmed, lowercased and stripped of the byte-order mark that Excel adds to UTF-8 files, so Email and email both match.

The two calls at the core of the import look like this:

// Create a new item
$handler->update_item( array( 'name' => 'Ana Lopez', 'email' => 'ana@example.com' ) );

// Update the item with ID 15
$handler->update_item( array( '_ID' => 15, 'name' => 'Ana López' ) );

To add more columns, add the field to the CCT, find its column with array_search(), sanitize the value and add it to $item under its Name/ID.

Test the import

  1. Create a private page, add the [bmc_cct_csv_import] shortcode, and open the page while logged in as an administrator.
  2. Upload a three-row CSV with one invalid email. You should see Import finished: 2 created, 0 updated, 1 skipped. followed by the line number of the bad row.
  3. Open the Customers CCT in the admin. The two valid rows appear as items.
  4. Upload the same file again. You should now see 0 created, 2 updated, and no duplicate items.
  5. If you change capability to let a non-admin role import, test with a user of that role before handing the page over. Some handler methods run their own permission checks.

Run code after each imported item

Because the import goes through the item handler, JetEngine's hooks fire for every row. Replace cliente_cct with your slug:

  • jet-engine/custom-content-types/created-item/cliente_cct passes $item, $item_id, $item_handler
  • jet-engine/custom-content-types/updated-item/cliente_cct passes $item, $prev_item, $item_handler
  • the filter jet-engine/custom-content-types/item-to-update passes $item, $fields, $item_handler and lets you change data before it is saved

For new items JetEngine also fires updated-item right after created-item, with an empty $prev_item. Check empty( $prev_item ) if you only want to react to real updates.

add_action( 'jet-engine/custom-content-types/created-item/cliente_cct', function ( $item, $item_id, $item_handler ) {
    error_log( 'New customer item ' . $item_id );
}, 10, 3 );

These hooks fire for every row. Be careful with heavy work like sending emails during a large import. Once your items are in, you will usually display them with a Listing Grid. Our guide to common JetEngine Query Builder mistakes helps you avoid empty or wrong listings.

Moving a CCT and its items to another site

  1. On the source site, go to JetEngine > Skins Manager > Export Skin and export the CCT as a .json file. This carries the CCT, its settings and its fields. The "Sample content" toggle adds only one sample item.
  2. On the target site, use JetEngine > Skins Manager > Import Skin to load the .json file.
  3. Import the items with Option 1 or Option 2 above.

If your CCT uses relations, for example to a taxonomy, the relation data needs its own import. The Crocoblock import and export article links a video on importing relations. If the data lives outside WordPress, you can also turn on the CCT's REST API endpoints and create items with an HTTP request. Each endpoint has its own Access Capability setting.

Troubleshooting

  • "CCT not found": the slug in the config doesn't match the CCT slug, or your custom code calls get_content_types() before init priority 12.
  • "The first row must contain the column names": the headers don't match the field Name/IDs, or the file uses semicolons. Excel uses semicolons in some locales. Set 'delimiter' => ';'.
  • Every row skipped as invalid email: the columns are probably shifted because of the delimiter. Open the file in a text editor to check.
  • "The CCT needs fields with the Name/IDs": your CCT is missing a field called exactly name or email. Rename the CCT fields, or change the names in the code as described above.
  • Table doesn't exist (custom SQL): you hard-coded wp_. Build the name as $wpdb->prefix . 'jet_cct_' . $slug, or better, use the API.
  • Timeouts on big files: split the CSV into smaller files and lower max_rows. Every row runs a lookup and a save, so batching keeps each request short.
  • CCT is empty after a Skins Manager import: this is expected. Skins Manager doesn't move items.
  • The form doesn't show: you are logged out or your role lacks the configured capability. That is by design, so the importer never shows up publicly.

Keep JetEngine updated to the latest version, since it handles data that site visitors can see. With the CCT set up and headers matching its fields, either route gets your CSV into the CCT in minutes, and the PHP version gives you validation, upserts and a clear report. If you would rather have a custom importer built for your project, post a request on BoostMyCroco.

Frequently Asked Questions

Can JetEngine import a CSV into a CCT out of the box?
JetEngine's Skins Manager imports a CCT's structure and fields, but not its items. Crocoblock documents importing CCT items with WP Ultimate CSV Importer Pro. You can also write a small PHP importer that saves each row with the CCT item handler's update_item() method.
Why doesn't Skins Manager import my CCT items?
Skins Manager only exports and imports the CCT definition, its settings and its fields. The Sample content toggle adds one sample item at most. To move the actual items, export them to CSV and import them with a CSV importer plugin or custom code.
How do I update existing CCT items instead of creating duplicates?
Look up the item first, for example by email, using prepare_query_args() and $type->db->query(). If you find a match, add its '_ID' to the array you pass to update_item(), and JetEngine updates that item instead of creating a new one.
What is the database table name of a JetEngine CCT?
It is your database prefix plus jet_cct_ plus the CCT slug, so slug cliente_cct gives wp_jet_cct_cliente_cct on a site with the default prefix. You can only change it by changing the slug. In code, build it with $wpdb->prefix instead of hard-coding wp_.
Can I import CCT items through the REST API?
Yes. In the CCT settings you can enable REST API endpoints to get, create, update or delete items, each with its own Access Capability. A script outside WordPress can then send one create request per CSV row.

Sources

  1. How to Import and Export Custom Content Type (crocoblock.com)
  2. JetEngine CSV Import: Import Custom Fields & CCTs (smackcoders.com)
  3. Import to JetEngine From CSV, Excel, and XML - WP All Import (wpallimport.com)
  4. JetEngine CCT CRUD · GitHub - Gist (gist.github.com)
  5. Custom Content Type (CCT) Overview — JetEngine (crocoblock.com)
  6. WordPress Dynamic Structure with JetEngine Tools - Crocoblock (crocoblock.com)
  7. GitHub - drakulux/koodesk-importer: Students CCT importer for Jetengine · GitHub (github.com)

Related tips