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

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
- Before you import: set up the CCT and the CSV
- Option 1: Import with WP Ultimate CSV Importer Pro
- Option 2: PHP upload form using the CCT API
- How the code works
- Test the import
- Run code after each imported item
- Moving a CCT and its items to another site
- Troubleshooting
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
- 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.
- 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. - Add the fields. In the Fields block, add two text fields with the Name/ID values
nameandemail. 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. - 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.
- 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.comOption 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.
- Install and activate WP Ultimate CSV Importer Pro.
- Open the WP Ultimate CSV Importer menu and go to the Import/Export tab.
- Click Upload from Desktop and choose your CSV file.
- Select your CCT (Customers) from the dropdown.
- 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_optionscapability - 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
initat priority 12, andget_content_types()returns nothing before that. A shortcode runs while the page renders, well afterinit. If you move the import into your own hook, useinitat 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_IDit creates a new item. With_IDit updates that item. - Duplicate check.
prepare_query_args()builds a query foremail = 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
Emailandemailboth 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
- Create a private page, add the
[bmc_cct_csv_import]shortcode, and open the page while logged in as an administrator. - 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.
- Open the Customers CCT in the admin. The two valid rows appear as items.
- Upload the same file again. You should now see 0 created, 2 updated, and no duplicate items.
- If you change
capabilityto 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_cctpasses$item, $item_id, $item_handlerjet-engine/custom-content-types/updated-item/cliente_cctpasses$item, $prev_item, $item_handler- the filter
jet-engine/custom-content-types/item-to-updatepasses$item, $fields, $item_handlerand 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
- 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.
- On the target site, use JetEngine > Skins Manager > Import Skin to load the .json file.
- 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()beforeinitpriority 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
nameoremail. 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?
Why doesn't Skins Manager import my CCT items?
How do I update existing CCT items instead of creating duplicates?
What is the database table name of a JetEngine CCT?
Can I import CCT items through the REST API?
Sources
- How to Import and Export Custom Content Type (crocoblock.com)
- JetEngine CSV Import: Import Custom Fields & CCTs (smackcoders.com)
- Import to JetEngine From CSV, Excel, and XML - WP All Import (wpallimport.com)
- JetEngine CCT CRUD · GitHub - Gist (gist.github.com)
- Custom Content Type (CCT) Overview — JetEngine (crocoblock.com)
- WordPress Dynamic Structure with JetEngine Tools - Crocoblock (crocoblock.com)
- GitHub - drakulux/koodesk-importer: Students CCT importer for Jetengine · GitHub (github.com)


