Stage system reference (developer)

June 5, 2026

The SSI import pipeline is built as a registry of named stages with priorities. The user-facing how it works article covers the conceptual stages; this article is the developer reference for the registration API, filter chain, and parallel-vs-sequential split.

Audience: add-on authors, integrators, and anyone building a custom template.

Registering a stage

Inside a template class (extending SSI_ImportTemplate, SSI_PostTemplate, etc.), override registerTemplateStages():

class My_Custom_Template extends SSI_PostTemplate {
    protected function registerTemplateStages() {
        parent::registerTemplateStages();   // keep the inherited stages

        $this->registerStage(
            'my-custom-stage',                  // name (unique across all stages)
            85,                                 // priority (lower = earlier)
            [$this, 'stage_my_custom_stage'],   // callable
            'Description shown in the admin UI'
        );
    }

    public function stage_my_custom_stage() {
        // your stage logic — runs in $this context
        // $this->table_names['batch'] is the per-import batch table
        // $this->config holds the parsed import config
    }
}

registerStage() is a method on the abstract template class (import-template-abstract.php). Add-on plugins (without a custom template) can register via the ssi_register_stages action — pass the template instance to registerStage():

add_action('ssi_register_stages', function($template) {
    $template->registerStage(
        'my-addon-stage',
        92,
        function() use ($template) { /* ... */ },
        'My add-on stage'
    );
});

Use registerStage (the public alias since 2.40+) when registering from outside the template class — it’s identical to registerStage() internally but accessible from add-on code.

Stage priorities (priority bands)

Lower priority runs first. The convention:

Band Phase Examples
10 Load load-csv
20 Taxonomies import-taxonomies
30 Match match-existing
40-49 Update existing update-posts, update-postmeta
50-59 Insert new insert-posts, insert-postmeta
60-69 Relationships upsert-relationships
70-89 Images attach-existing-images, upload-remote-images, attach-gallery-images
90-95 Post-processing fix-attributes, process-deletes
999 Cleanup (proposed) post-import-cleanup

You’re free to pick any integer for your stage. Stick to the bands where possible so it slots into the natural execution order. If your stage needs to run after upsert-relationships but before image stages, use a priority around 65.

The ssi_filter_stages_to_run filter

Every run filters the registered stage list through ssi_filter_stages_to_run before dispatching. The filter signature:

apply_filters('ssi_filter_stages_to_run', $stages_assoc, $config, $template);

$stages_assoc is an associative array keyed by stage name. Return a filtered associative array (typically by unset()‘ing keys you want to skip, or building a new array with only the keys you want).

Priority is the standard WP filter priority and controls who-wins-when-multiple-filters-compose.

Built-in filter registrations (priorities)

Source Priority Effect
Saved import-mode preset 5 Restricts to the preset’s allowed stages
CLI comma-separated list (wp ssi 5 a,b,c) 10 Restricts to listed stages
--delete-all flag 99 Forces only process-deletes

This three-tier precedence lets you build presets, override per-run via CLI, and have --delete-all always trump everything.

Adding your own filter

To add an extra preset:

add_filter('ssi_filter_stages_to_run', function($stages, $config, $template) {
    if (!empty($template->additional_options['import_mode'])
        && $template->additional_options['import_mode'] === 'my_custom_mode') {
        return array_intersect_key($stages, array_flip([
            'load-csv', 'match-existing', 'my-custom-stage',
        ]));
    }
    return $stages;
}, 4, 3);  // priority 4 — runs BEFORE the core preset filter at 5

To compose on top of an existing preset:

add_filter('ssi_filter_stages_to_run', function($stages, $config, $template) {
    // Remove image stages unconditionally
    foreach (['attach-existing-images', 'upload-remote-images', 'attach-gallery-images'] as $img_stage) {
        unset($stages[$img_stage]);
    }
    return $stages;
}, 11, 3);  // priority 11 — runs AFTER the CLI filter at 10

Parallel vs sequential stages

Most stages run sequentially. Only these are marked parallel:

$parallel_stages = [
    'load-csv'                => 'csv',
    'attach-existing-images'  => 'batch',
    'upload-remote-images'    => 'batch',
    'fix-attributes'          => 'batch',
];

The value ('csv' or 'batch') tells the orchestrator how to segment the work:

  • 'csv' — split the CSV file by byte offset and assign each segment to a worker.
  • 'batch' — split the batch table by post_id range.

To make your custom stage parallel, register it as parallel via the orchestrator metadata. This is done by overriding getStagesWithParallelInfo() in your template, or by hooking the post-registration loop in super-speedy-imports.php (the latter requires reading the orchestrator source). Most add-ons stay sequential.

Don’t parallelise a stage that writes to global state without coordination. The classic violation is wp_insert_term — concurrent workers race on the wp_terms / wp_term_taxonomy UNIQUE keys. As of 2.55.8 SSI recovers from that race (it retries via term_exists() instead of aborting on the duplicate-key error), but it still serialises import-taxonomies by policy — both because the race is cheaper to avoid than to recover from, and because parallelism measured only ~7% faster at 100k.

Add-on authors, take note: ssi_perf_effective_workers($stage_name, $default) in functions/perf-options.php hard-returns 1 for import-taxonomies before consulting any stage registration or saved worker config. So even if you mark a taxonomy-writing stage parallel, or copy the parallel-stage pattern for import-taxonomies itself, that short-circuit forces it back to a single worker — and the Performance Options Workers dropdown for it is silently ignored.

Hooks that fire during a run

do_action('ssi_register_stages', $template);    // when stages get registered
do_action('ssi_template_init', $template);      // right after stage registration
do_action('ssi_before_import_stages', $template);   // just before the stage loop starts
// (stage callbacks fire here, one at a time)
do_action('ssi_after_import_stages', $template);    // just after the stage loop ends

Use ssi_register_stages to add or remove stages. Use ssi_template_init to set up state that all your custom stages need. Use the before/after hooks to wrap the whole pipeline.

Logging from inside a stage

Every template has a $this->logger (instance of ImportLogger). The key methods:

$this->logger->logStageStart($stage_name);        // already called by the orchestrator
$this->logger->logStageMetric($stage, $key, $value);   // numeric stat shown in the summary
$this->logger->logStageDetail($stage, $msg);      // free-form note attached to the stage row
$this->logger->updateSummaryMetrics($array);      // top-level summary stat
$this->logger->logStageEnd($stage_name);          // already called by the orchestrator

logStageMetric('upsert-relationships', 'product_cat_created', 850000) appears in the CLI summary table AND persists to wp_ssi_import_history_stages.metrics so you can query it post-run.

Re-running a single stage

The orchestrator supports wp ssi <id> <stage-name> for any registered stage. Your custom stage needs to be IDEMPOTENT for this to work — re-running it should produce the same end state whether it’s the first run or the 50th.

Pattern: do all destructive work (DELETE) before the constructive work (INSERT). For relationship-style stages:

-- Delete relationships this stage might have created
DELETE tr FROM wp_term_relationships tr
INNER JOIN wp_super_speedy_imports_batch_<id> it ON tr.object_id = it.post_id
INNER JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = 'my-taxonomy';

-- Recreate them from scratch
INSERT INTO wp_term_relationships (object_id, term_taxonomy_id)
SELECT DISTINCT it.post_id, tt.term_taxonomy_id
FROM wp_super_speedy_imports_batch_<id> it
INNER JOIN wp_terms t ON ...
INNER JOIN wp_term_taxonomy tt ON ...;

A failing re-run after an aborted earlier run leaves the table in the expected end state, not a half-state.

Per-import staging tables

Every stage has access to these via $this->table_names:

Key Table Purpose
batch wp_super_speedy_imports_batch_<id> One row per CSV row, all mapped fields plus computed columns
terms wp_super_speedy_imports_terms_<id> Flat-taxonomy terms found in the CSV
hierarchies wp_super_speedy_imports_hierarchies_<id> Hierarchical-taxonomy paths found in the CSV
gallery_images wp_super_speedy_imports_gallery_images_<id> Gallery URLs (WC only)
deletes wp_super_speedy_imports_deletes_<id> IDs flagged for deletion in process-deletes
duplicate_unique_ids wp_ssi_duplicate_unique_ids_<id> Rows whose UID got -N suffix added
validation_errors wp_ssi_validation_errors_<id> Per-row errors (blank UIDs, etc.)

These survive between runs on purpose. load-csv drops and recreates them at the start of every full run. Your stage can read them but should treat write access as load-csv’s domain — don’t add or modify rows from a later stage unless you understand what downstream stages expect.

Useful helpers

ssi_query($sql, $params);              // wrapper around $wpdb->query — auto-dispatches to wpdb or mysqli
ssi_get_results($sql, $params);        // wrapper around $wpdb->get_results
ssi_get_var($sql, $params);            // wrapper around $wpdb->get_var
ssi_insert($table, $data, $format,
           $on_dup, $silent, $ignore); // wrapper around $wpdb->insert with INSERT IGNORE option

ssi_is_safe_identifier($name);         // SQL hardening — true if $name is safe to interpolate as a backtick identifier
ssi_perf_get($stage, $key, $default);  // read per-stage performance options (workers, query_limit, etc.)
ssi_report_import_error($msg, $fatal); // log an error; if $fatal=true, abort the import

ssi_query is preferred over direct $wpdb->query() because it routes through whichever DB connection the import is currently using (wpdb for sequential stages, raw mysqli for parallel-worker stages — both for memory-leak avoidance).

Cross-references

  • How imports work — the stage pipeline (user-facing) — the same content from the user’s perspective.
  • Creating custom templates — for setting up the template class that registers stages.
  • Creating add-ons — for stage registration from a plugin without a custom template.
  • Stage selection presets — for the user-facing layer that filters this registry.
×
1/1