---
title: "Textpattern to WordPress Migration: A Technical Guide"
slug: textpattern-to-wordpress-migration-a-technical-guide
date: 2026-08-18
author: Wahab
categories: [Migration Guide, General]
excerpt: "A technical guide to migrating from Textpattern to WordPress — covering database extraction, Textile-to-HTML conversion, media re-hosting, URL redirects, and common failure modes."
tldr: "Both platforms use MySQL, but Textpattern's Textile markup, flat sections, and numeric image filenames need explicit transformation. Use Body_html columns, build redirects before cutover, and re-host every image."
canonical: https://clonepartner.com/blog/textpattern-to-wordpress-migration-a-technical-guide/
---

# Textpattern to WordPress Migration: A Technical Guide


## What Changes When You Move from Textpattern to WordPress

A **Textpattern to WordPress migration** transfers articles, categories, comments, images, files, links, and users from Textpattern's MySQL schema into WordPress's `wp_posts`, `wp_terms`, `wp_comments`, and `wp_postmeta` tables — while converting markup, restructuring URLs, and re-hosting all media assets.

**Textpattern** is a lightweight PHP/MySQL CMS built around a flat, section-based content model. Content is organized into **Sections** (lateral structure, not hierarchical), with articles assigned to exactly one Section and up to two Categories. There is no native support for subsections. Textpattern uses **Textile** as its default markup language and relies on a tag-based template system (`<txp:article />`) for rendering.

**WordPress** uses a hierarchical taxonomy system. Posts can belong to unlimited categories and tags, support nested parent-child page structures, and store content as HTML (Classic Editor) or serialized block markup (Gutenberg). The REST API (`/wp-json/wp/v2/`) provides a full CRUD surface for programmatic imports.

The architectural gap means every migration decision involves a structural translation, not just a data copy. If you attempt to dump the `textpattern` table into `wp_posts`, you will end up with broken formatting, missing inline images, orphaned comments, and a catastrophic loss of organic search traffic.

**Estimated effort by site complexity:**

| Site profile | Estimated effort |
|---|---|
| <200 articles, minimal images, no custom fields | 1–2 days |
| 500–2,000 articles, standard media, basic custom fields | 3–5 days |
| 2,000+ articles, custom field business data, comment history, strict SEO requirements | 1–2 weeks |

These estimates assume: one person with SQL/Python proficiency, a staging environment already running, and no delta sync requirement. Each variable (additional authors, glz_custom_fields plugins, ID-based URLs) adds 0.5–1 day.

## Textpattern's Database Schema: What You're Extracting

Before writing any migration code, understand what lives where in Textpattern's database. ([docs.textpattern.com](https://docs.textpattern.com/development/database-schema-reference))

The tables that matter for migration:

| Textpattern Table | Contains | WordPress Target |
|---|---|---|
| `textpattern` | Articles (body, excerpt, metadata) | `wp_posts` |
| `txp_category` | Categories (article, image, file, link) | `wp_terms` + `wp_term_taxonomy` |
| `txp_discuss` | Comments | `wp_comments` |
| `txp_image` | Image metadata (files on disk in `/images/`) | `wp_posts` (attachment) + `/wp-content/uploads/` |
| `txp_file` | File download metadata (files on disk) | `wp_posts` (attachment) |
| `txp_link` | Blogroll-style links | `wp_links` (deprecated) or custom |
| `txp_users` | User accounts and roles | `wp_users` + `wp_usermeta` |
| `txp_section` | Sections (site structure) | Categories, pages, or custom taxonomy |

### The `txp_section` Table

The `txp_section` table defines each section's slug, title, and behavior. Key columns:

- **`name`** (VARCHAR 64): The section slug used in URLs (e.g., `articles`, `blog`).
- **`title`** (VARCHAR 255): Human-readable section name.
- **`permlink_mode`**: Per-section URL format override. Values mirror the global `permlink_mode` in `txp_prefs`: `section_title`, `title_only`, `id_title`, `year_month_day_title`, `messy`. This controls which URL pattern each section uses — critical for building accurate redirect rules.
- **`on_front_page`** (tinyint): Whether articles in this section appear on the site front page.
- **`searchable`** (tinyint): Whether the section is included in site search.

Query it before mapping sections to WordPress:

```sql
SELECT name, title, permlink_mode, on_front_page
FROM txp_section
ORDER BY name;
```

### The Article Table in Detail

The `textpattern` table is the core of any migration. Key columns and their implications:

- **`Body`** (MEDIUMTEXT): Raw article text as authored — usually Textile markup.
- **`Body_html`** (MEDIUMTEXT): Pre-rendered HTML version, converted from `Body` on save.
- **`textile_body`** (VARCHAR 32): Markup flag — `0` = raw HTML, `1` = Textile, `2` = convert line breaks only.
- **`Excerpt` / `Excerpt_html`**: Same dual-storage pattern as Body.
- **`Category1`, `Category2`** (VARCHAR 64): Only two category slots per article. No tags table — keywords live in a comma-separated `Keywords` field (VARCHAR 255).
- **`Section`** (VARCHAR 255): The section this article belongs to.
- **`Status`**: `1` = draft, `2` = hidden, `3` = pending, `4` = live, `5` = sticky.
- **`custom_1` through `custom_10`** (VARCHAR 255 each): Ten generic custom field slots. Labels are stored in `txp_prefs`, not in this table.
- **`url_title`** (VARCHAR 255): The URL slug for permalinks.
- **`LastMod`** (DATETIME): Timestamp of last modification. Use this column for delta sync detection — query `WHERE LastMod > '2024-01-01 00:00:00'` to find records changed since a prior export run.
- **`Expires`**: An explicit expiry datetime — WordPress has no native equivalent, so this typically becomes post meta plus custom logic or a plugin.

Before exporting anything, run audit queries to establish source-of-truth counts for validation:

```sql
SELECT COUNT(*) AS live_articles
FROM textpattern
WHERE Status IN (4, 5);

SELECT COUNT(*) AS visible_comments
FROM txp_discuss
WHERE visible = 1;

SELECT COUNT(*) AS images FROM txp_image;
SELECT COUNT(*) AS files  FROM txp_file;

SELECT Section, COUNT(*) AS articles
FROM textpattern
GROUP BY Section
ORDER BY articles DESC;
```

These counts become your validation targets. If the numbers don't match after import, something was dropped.

## Migration Paths: Which Approach Fits Your Site

There is no one-click Textpattern-to-WordPress tool that reliably handles everything. The decision tree below maps your site's profile to the appropriate approach.

```
Do you have > 500 articles?
├── No → Try WXR export script. Plugin if very small and old.
└── Yes → Do you have custom fields with business-critical data?
    ├── No → WXR export script with manual media pass
    └── Yes → Do you need zero-downtime cutover or delta sync?
        ├── No → Custom SQL-to-REST migration
        └── Yes → Custom SQL-to-REST with LastMod-based delta sync
```

| Path | Good fit | Main downside |
|---|---|---|
| TextPattern Importer plugin | Very small, old TXP blog (<50 articles) | Stale, low confidence; imports raw Textile not HTML |
| WXR exporter script | Standard editorial archive, no business-critical custom fields | No media handling; WXR files >10MB risk PHP timeout |
| Custom REST/WP-CLI loader | Complex sites, delta sync, zero-downtime cutover | More engineering upfront |
| RSS feed import | Personal blog, <20 posts | Loses comments, categories, custom fields, author attribution |

### 1. WordPress TextPattern Importer Plugin (Direct Database)

The official plugin imports categories, users, posts, comments, and links from a TextPattern blog. It's listed on WordPress.org, tested up to WordPress 6.7, and supports Textpattern versions 4.0.2 through 4.8.8. ([wordpress.org](https://wordpress.org/plugins/textpattern-importer/))

**The problem:** The plugin has well-documented reliability issues. Multiple WordPress.org support threads report zero items imported, and the plugin page shows a maintenance warning with very low active installs. It also imports the raw `Body` column (Textile markup), not the pre-rendered `Body_html` — meaning posts arrive in WordPress as unparsed Textile.

If you try this route, test on a staging instance first. Expect to troubleshoot PHP version incompatibilities and query failures. Treat it as a reconnaissance tool, not a production migration plan.

### 2. WXR Export Script (Recommended for Most Sites)

The most reliable community-tested approach uses a PHP script that reads your Textpattern database and generates a **WordPress eXtended RSS (WXR)** file. WordPress's own import documentation points Textpattern users toward this method. ([developer.wordpress.org](https://developer.wordpress.org/advanced-administration/wordpress/import/))

Two maintained options:

- **Drew McLellan's `textpattern-to-wordpress`** — Place the PHP file at the same level as the Textpattern folder, then load it in your browser. The `$export_html` setting defaults to `true`, exporting pre-rendered `Body_html` rather than raw Textile. Exports posts, categories, and comments. Does not handle images or custom fields.
- **Droow's `textpattern-to-wordpress-exporter`** — Similar approach with a `?exportRaw` parameter for Textile output instead of HTML. Does not export Textpattern custom fields. Use Drew McLellan's version unless you have a specific reason to export raw Textile.

**WXR file size limits:** WordPress's built-in importer has no enforced file size cap, but PHP memory limits typically cause failures above 10–15MB. For sites over ~500 articles, split the WXR file by date range or section before importing. Importing a partial file and retrying the full file creates duplicate posts — split before you start, not after a failure.

Once you have the WXR file, import it via **Tools → Import → WordPress** in your WP admin. This handles posts, categories, and comments in a single pass. Images and section-to-taxonomy mapping require separate steps.

### 3. Custom SQL-to-REST API Migration (Full Control)

For sites with complex custom fields, thousands of articles, or strict data-integrity requirements, write a custom migration script that reads directly from the Textpattern MySQL database, transforms each record, and writes to WordPress via the REST API or `wp_insert_post()` through WP-CLI.

```python
# Simplified: Extract TXP articles and push to WordPress REST API
import pymysql
import requests
import base64
import time

txp_db = pymysql.connect(host='localhost', user='txp_user',
                         password='xxx', database='txp_database')
cursor = txp_db.cursor(pymysql.cursors.DictCursor)

cursor.execute("""
    SELECT ID, Title, Body_html, Excerpt_html, Posted, LastMod,
           Category1, Category2, Section, Status, url_title, Keywords,
           custom_1, custom_2, custom_3
    FROM textpattern
    WHERE Status = 4
    ORDER BY ID ASC
""")

wp_url = "https://newsite.com/wp-json/wp/v2/posts"
creds = base64.b64encode(b"admin:application-password").decode()
headers = {"Authorization": f"Basic {creds}"}

for article in cursor.fetchall():
    wp_status = "publish" if article['Status'] == 4 else "draft"
    post_data = {
        "title": article['Title'],
        "content": article['Body_html'],
        "excerpt": article['Excerpt_html'] or "",
        "status": wp_status,
        "slug": article['url_title'],
        "date": article['Posted'].isoformat(),
        # import_id preserves the original TXP article ID in wp_posts.ID
        # This is required if your old URLs include numeric IDs (/article/123/slug)
        # REST API field: not supported natively — use wp_insert_post() with 'import_id' key
        "meta": {
            "_txp_id": str(article['ID']),
            "_txp_section": article['Section'],
            "_txp_url_title": article['url_title'],
            "_txp_custom_1": article['custom_1'] or "",
        }
    }
    response = requests.post(wp_url, headers=headers, json=post_data)
    print(f"Article {article['ID']}: {response.status_code}")
    time.sleep(0.3)  # Avoid overloading PHP-FPM workers or triggering WAF rate limits
```

**Preserving article IDs:** The WordPress REST API does not expose an `import_id` field. To force WordPress to use the original Textpattern article ID (required when old URLs contain numeric IDs), use `wp_insert_post()` with the `import_id` key:

```php
$post_id = wp_insert_post([
    'import_id'    => $txp_article_id,  // Forces wp_posts.ID to this value
    'post_title'   => $title,
    'post_content' => $body_html,
    'post_status'  => 'publish',
    'post_name'    => $url_title,
]);
```

Run this via WP-CLI eval-file or a custom plugin activation hook. Note: `import_id` silently fails if the ID already exists in `wp_posts`. Run `DELETE FROM wp_posts WHERE ID < 1` on a fresh install before using this approach, and verify post counts afterward.

**Delta sync:** To sync only records changed since a prior export run, filter on the `LastMod` column:

```sql
-- Find articles modified after the initial export
SELECT ID, Title, Body_html, LastMod
FROM textpattern
WHERE LastMod > '2024-03-01 00:00:00'
  AND Status = 4
ORDER BY LastMod ASC;
```

In your migration script, track the export timestamp, then query `WHERE LastMod > :last_export_time` on each subsequent run. For deletes, Textpattern doesn't soft-delete by default — articles removed from the database won't appear in this query. Run a full ID reconciliation (`SELECT ID FROM textpattern` vs. your WordPress `_txp_id` meta values) before final cutover.

REST API pagination is capped at 100 records per request. ([developer.wordpress.org](https://developer.wordpress.org/rest-api/using-the-rest-api/pagination/))

## Textile to HTML: The Markup Conversion

Textile is a lightweight markup language developed by Dean Allen — who also created Textpattern. It's the default formatting engine, and most articles will have `textile_body = 1`.

The good news: Textpattern stores both raw Textile and pre-rendered HTML. The `Body_html` column contains exactly what visitors have been seeing on your site.

**Use `Body_html`, not `Body`.** Importing raw Textile into WordPress will display unparsed notation like `h2. Heading` and `*bold text*` as literal text. The pre-rendered HTML column eliminates the need to parse Textile at migration time and guarantees front-end fidelity.

Check the `textile_body` flag for each article:

| `textile_body` value | Meaning | Which column to import |
|---|---|---|
| `0` | Raw HTML (no conversion) | `Body` or `Body_html` — both are HTML |
| `1` | Textile markup | `Body_html` — use the rendered version |
| `2` | Line breaks only (auto-paragraphs) | `Body_html` — use the rendered version |

In all three cases, `Body_html` is the safer import source. Verify it's populated across your full dataset before committing:

```sql
SELECT textile_body, COUNT(*) AS article_count,
       SUM(CASE WHEN Body_html = '' OR Body_html IS NULL THEN 1 ELSE 0 END) AS empty_body_html
FROM textpattern
WHERE Status IN (4, 5)
GROUP BY textile_body;
```

If any rows show `empty_body_html > 0` for `textile_body = 1`, those articles need Textile parsing as a fallback.

**Converting to Gutenberg blocks:** If you need Gutenberg block markup rather than Classic Editor HTML:
1. Import as HTML into WordPress (Classic Editor compatible)
2. Use the built-in per-post "Convert to Blocks" tool, or use the **WP Migrate** plugin's bulk block conversion feature for batch processing

Don't parse Textile directly into Gutenberg blocks. Edge cases — Textile tables, image alignment syntax, footnotes — will fail in unpredictable ways.

**Fallback: Textile parsing.** If `Body_html` is empty or corrupt, parse the raw Textile using the `Netcarver\Textile` library — the same parser modern Textpattern uses:

```php
use Netcarver\Textile\Parser;
$parser = new Parser();
$html = $parser->parse($raw_textile);
```

## Section-to-Category Mapping

Textpattern's structural model combines **Sections** and **Categories**. Every article belongs to exactly one Section, plus up to two Categories. Sections control URL context, page templates, and front-page visibility. There is no native subsection hierarchy.

WordPress has no direct "section" equivalent. Your mapping options:

| TXP Concept | WordPress Mapping | When to Use |
|---|---|---|
| Section | Category | If sections represent topic groupings |
| Section | Parent page | If sections represent static site areas |
| Section | Custom taxonomy | If you need clean separation from post categories |
| Category1/Category2 | Categories or tags | Direct mapping |
| Keywords | Tags | Natural fit — both are flat, comma-separated |

Textpattern's `txp_category` table uses a **modified preorder tree traversal** algorithm (nested set model with `lft` and `rgt` columns) for hierarchy. WordPress uses a simpler parent-child model in `wp_term_taxonomy`. Flatten the nested-set hierarchy and rebuild it using WordPress's `parent` field:

```sql
-- Retrieve Textpattern categories in hierarchical order
SELECT id, name, title, type, lft, rgt,
       (SELECT p.name FROM txp_category p
        WHERE p.lft < c.lft AND p.rgt > c.rgt AND p.type = c.type
        ORDER BY p.lft DESC LIMIT 1) AS parent_name
FROM txp_category c
WHERE type = 'article'
ORDER BY lft;
```

Map `parent_name` to the WordPress term's `parent` parameter in `wp_insert_term()`.

A sensible default: map Textpattern Sections to WordPress Categories (preserving the primary content silo), Category1/Category2 to subcategories or tags, and Keywords to tags. If `Category2` functions as a facet, audience label, or secondary classifier on your current site, model it intentionally rather than appending it to a generic category list.

## URL Structure and Redirects

Getting this wrong tanks your SEO. As we emphasize in our [migration checklist](https://clonepartner.com/blog/blog/the-ultimate-knowledge-base-migration-checklist-a-zero-downtime-plan/), every Textpattern URL that returns a 404 instead of a 301 after migration bleeds link equity.

**Textpattern URL patterns** (per `permlink_mode` in `txp_prefs` and per-section in `txp_section`):
- `/section/title` — most common clean URL (`section_title` mode)
- `/section/id/title` — ID-based (`id_title` mode)
- `/year/month/day/title` — date-based (`year_month_day_title` mode)
- `/?id=123` — messy mode
- `/title` — section-less (`title_only` mode)

Query the actual permlink mode in use before building redirect rules:

```sql
SELECT name, val FROM txp_prefs
WHERE name = 'permlink_mode';
```

**WordPress URL patterns** (Settings → Permalinks):
- `/%postname%/` — most common
- `/%category%/%postname%/`
- `/%year%/%monthnum%/%day%/%postname%/`

The typical Textpattern site uses `/section/url_title`. WordPress doesn't natively replicate this without using categories in the permalink structure or writing custom rewrite rules.

**Your redirect strategy:**

1. **Audit every existing URL.** Crawl your Textpattern site with Screaming Frog or `wget --spider` before cutover to build a complete list of live URLs.
2. **Generate 301 redirects.** For each old URL, create a redirect to the new WordPress URL. Because you retained `url_title` and `Section` during extraction, you can generate the map programmatically.
3. **Deploy at the server level.** For sites with hundreds of articles, use Nginx maps or import a CSV redirect map into a WordPress plugin (like Redirection) rather than bloating `.htaccess`.

```apache
# Redirect legacy TXP section/title URLs to WP postname URLs
Redirect 301 /articles/my-old-post https://newsite.com/my-old-post/
Redirect 301 /blog/another-post https://newsite.com/another-post/
```

For Nginx, use a map block to handle large redirect sets without performance degradation:

```nginx
# In nginx.conf http block
map $request_uri $redirect_uri {
    /articles/my-old-post  /my-old-post/;
    /blog/another-post     /another-post/;
}
server {
    if ($redirect_uri) {
        return 301 $redirect_uri;
    }
}
```

If your Textpattern URLs include numeric IDs (`/article/123/my-post`), either preserve those IDs in WordPress using `import_id` in `wp_insert_post()` (see above), or write ID-to-slug redirect rules from your stored `_txp_id` post meta.

Build and test your redirect map **before** cutover. Monitor Google Search Console and server logs for 404s during the first 2–4 weeks post-migration.

## Migrating Images and Files

Textpattern and WordPress handle media completely differently.

Textpattern stores image metadata in `txp_image` (width, height, alt text, caption, author, date), but the actual image files live in the `/images/` directory using the image ID as the filename — e.g., `1.png` for the main image, `1t.png` for the thumbnail. ([docs.textpattern.com](https://docs.textpattern.com/development/database-schema-reference)) File downloads follow the same pattern in `txp_file`.

WordPress stores media in `/wp-content/uploads/YYYY/MM/` with original filenames, and creates an `attachment` post type record in `wp_posts` with metadata in `wp_postmeta`.

**Migration steps:**

1. **Copy files** from Textpattern's `/images/` and `/files/` directories to a staging location.
2. **Query `txp_image`** to get the mapping of ID to original filename, alt text, and caption:

```sql
SELECT id, name, ext, alt, caption, w, h, date
FROM txp_image
ORDER BY id;
```

The file on disk is `{id}{ext}` (e.g., `42.jpg`) for the full image and `{id}t{ext}` (e.g., `42t.jpg`) for the thumbnail.

3. **Upload to WordPress** via the REST API `/wp/v2/media` endpoint, `media_handle_sideload()`, or `wp media import`. Preserve alt text and captions from the source metadata.
4. **Build an ID-to-URL map.** Record the WordPress media URL for each original Textpattern image ID. You'll need this for content rewriting.
5. **Rewrite image references** in all article content. Textpattern articles reference images via TXP tags (`<txp:image id="5" />`), Textile image shorthand (`!/images/5.jpg!`), or direct paths (`/images/5.jpg`). Replace all three formats:

```php
// Replace TXP image tags
$content = preg_replace_callback(
    '/<txp:image id="(\d+)"[^\/]*\/?>/',
    function ($matches) use ($image_map) {
        $id = $matches[1];
        $url = $image_map[$id]['wp_url'];
        $alt = htmlspecialchars($image_map[$id]['alt']);
        return '<img src="' . $url . '" alt="' . $alt . '" />';
    },
    $content
);

// Replace Textile image shorthand !/images/ID.ext!
$content = preg_replace_callback(
    '/!\/images\/(\d+)(\.\w+)!/',
    function ($matches) use ($image_map) {
        $id = $matches[1];
        return '!' . $image_map[$id]['wp_url'] . '!';
    },
    $content
);
```

For bulk replacement of direct `/images/ID.ext` paths after content is in WordPress, use WP-CLI:

```bash
wp search-replace '/images/' 'https://newsite.com/wp-content/uploads/' --skip-columns=guid
```

6. **Let WordPress handle thumbnails.** Textpattern's `{id}t.ext` thumbnails won't transfer directly — WordPress regenerates its own thumbnail sizes based on your theme's registered image sizes. Run `wp media regenerate` after uploading all images.

**The `Image` field warning:** Textpattern's article `Image` column can contain a numeric image ID, a comma-separated list of IDs (for galleries), or an external URL. Parse all three cases explicitly — treating it as a single featured-image column will drop galleries and break URL-referenced assets.

## Custom Fields and Post Meta

Textpattern provides 10 generic custom fields (`custom_1` through `custom_10`), each a VARCHAR(255). The labels for these fields are not stored in the `textpattern` table — they live in `txp_prefs`.

**Query `txp_prefs` to retrieve custom field labels:**

```sql
SELECT name, val
FROM txp_prefs
WHERE name LIKE 'custom_%_set'
ORDER BY name;
-- Returns rows like: custom_1_set = 'Author Bio', custom_2_set = 'Source URL', etc.
```

Use these labels as your WordPress meta keys for readability and ACF compatibility.

WordPress stores custom data as key-value pairs in `wp_postmeta` (unlimited). To migrate:

1. Query `txp_prefs` for the human-readable label for each `custom_N_set` entry.
2. Map non-empty `custom_N` values to `wp_postmeta` using the readable label as the meta key.
3. If using Advanced Custom Fields (ACF) on the WordPress side, register the field groups first, then insert values using ACF's expected meta key format (`field_XXXXXXXX` for internal references, or the field name for display).

```php
// Map TXP custom field labels from txp_prefs, then insert into wp_postmeta
foreach ($custom_field_labels as $field_num => $label) {
    $value = $txp_article["custom_{$field_num}"];
    if (!empty($value)) {
        update_post_meta($wp_post_id, sanitize_key($label), $value);
    }
}
```

If your Textpattern setup used the **`glz_custom_fields`** plugin to exceed the 10-field limit, that plugin stores its data in a separate `glz_custom_fields` table (field definitions) and `glz_field_types` table (values). You must query those tables separately and merge them into your migration payload — they will not appear in the main `textpattern` table's `custom_N` columns.

## User and Role Mapping

Textpattern's privilege levels don't map 1:1 to WordPress roles:

| TXP Privilege | TXP Level | WordPress Equivalent |
|---|---|---|
| Publisher | 1 | Administrator |
| Managing Editor | 2 | Editor |
| Copy Editor | 3 | Editor (limited) |
| Staff Writer | 4 | Author |
| Freelancer | 5 | Contributor |
| Designer | 6 | No direct equivalent — Editor or custom role |

Create WordPress users with the same username as in Textpattern **before** importing articles, so author attribution maps correctly. **Passwords cannot be migrated.** Textpattern historically used a salted SHA-based mechanism, while WordPress uses the Portable PHP password hashing framework (phpass). All users must reset passwords post-migration. Send password reset emails immediately after cutover — do not wait for users to notice they can't log in.

## Comments Migration

Textpattern stores comments in `txp_discuss`. The field mapping to `wp_comments` is clean:

| `txp_discuss` | `wp_comments` | Notes |
|---|---|---|
| `discussid` | `comment_ID` | Preserve to maintain any existing comment permalinks |
| `parentid` | `comment_post_ID` | Must map to new WordPress post ID, not original TXP article ID |
| `name` | `comment_author` | |
| `email` | `comment_author_email` | |
| `web` | `comment_author_url` | |
| `ip` | `comment_author_IP` | |
| `posted` | `comment_date` | |
| `message` | `comment_content` | |
| `visible` | `comment_approved` | 1→1 (visible), 0→0 (pending), -1→'spam' |

**Handling spam and moderated comments:**

```sql
-- Insert visible comments as approved
INSERT INTO wp_comments (comment_post_ID, comment_author, comment_content, comment_approved, ...)
SELECT id_mapping.wp_id, d.name, d.message, 1, ...
FROM txp_discuss d
JOIN id_mapping ON d.parentid = id_mapping.txp_id
WHERE d.visible = 1;

-- Insert spam as marked spam
INSERT INTO wp_comments (..., comment_approved)
SELECT ..., 'spam'
FROM txp_discuss d
WHERE d.visible = -1;
```

**Comment orphaning prevention:** If article IDs shift during migration, comments referencing old `parentid` values will detach from their posts. Maintain an ID mapping table (`txp_id` → `wp_post_id`) throughout the process and join against it when inserting comments. Never insert comments before the article ID mapping table is complete.

Update `wp_posts.comment_count` after inserting all comments:

```sql
UPDATE wp_posts p
SET comment_count = (
    SELECT COUNT(*) FROM wp_comments c
    WHERE c.comment_post_ID = p.ID AND c.comment_approved = 1
);
```

## Step-by-Step Migration Workflow

- [ ] **Back up everything.** Export a full MySQL dump and copy all site files, including `/images/` and `/files/`.
- [ ] **Audit your data.** Run article, comment, image, file, and section counts. Identify custom field usage patterns and `textile_body` distribution across your dataset.
- [ ] **Query `txp_prefs`** for custom field labels and `permlink_mode`. Query `txp_section` for per-section URL patterns.
- [ ] **Set up a staging WordPress instance.** Configure permalink structure, install plugins, and create user accounts matching Textpattern author usernames.
- [ ] **Map your data model.** Decide how Sections, Categories, Keywords, custom fields, hidden content, and expired content translate to WordPress. Document the mapping before writing any import code.
- [ ] **Export content.** Use a WXR export script with `$export_html = true`, or build a custom migration script for complex sites. Verify `Body_html` coverage with the audit query above before choosing your approach.
- [ ] **Import into staging.** Validate article counts, comment counts, author attribution, and category assignments against your audit numbers.
- [ ] **Build the article ID mapping table.** Record TXP article ID → WordPress post ID before migrating comments or redirects.
- [ ] **Migrate media.** Query `txp_image` for metadata. Copy files from `/images/` and `/files/`. Upload to WordPress media library. Build the image ID → WordPress URL map. Rewrite all in-content references (TXP tags, Textile shorthand, direct paths).
- [ ] **Build and test 301 redirects.** Every old URL must resolve before cutover. Test with `curl -I` against the staging environment.
- [ ] **Migrate comments.** Join against the ID mapping table. Set `comment_approved` values correctly. Update `comment_count` on all posts.
- [ ] **Spot-check 10–20 articles.** Verify formatting (especially Textile-heavy posts with tables, blockquotes, and nested lists), images, custom fields, and comment threads.
- [ ] **Delta sync.** Query `WHERE LastMod > :initial_export_timestamp` for records changed since the initial export. Run a full ID reconciliation to catch any deleted articles.
- [ ] **Cut over.** Switch DNS or server config to the WordPress site.
- [ ] **Monitor.** Watch Google Search Console and server logs for 404s over the first 2–4 weeks. Fix broken redirects or missing content immediately.

## Common Failure Modes

**Textile artifacts in post content.** If you import `Body` instead of `Body_html`, posts will contain raw Textile notation. Always use the `_html` columns. Verify coverage before import with the `textile_body` distribution query above.

**Broken internal links.** Textpattern articles may link to other articles using TXP tags (`<txp:article_url_title />`) or section-relative paths. These won't resolve in WordPress. Run a broken-link scan post-import with Screaming Frog or the Broken Link Checker plugin.

**Missing images.** Articles referencing `/images/42.jpg` will 404 if files aren't re-hosted and paths aren't rewritten. Handle TXP image tags, Textile image shorthand, and direct `/images/` paths as separate regex patterns.

**Lost article IDs.** If your old URLs include article IDs (`/article/123/my-post`) and the WordPress import assigns new IDs, those URLs break. Preserve IDs using `import_id` in `wp_insert_post()`.

**Comment orphaning.** If article IDs shift but comments still reference old `parentid` values, comments detach from posts. Always join against the ID mapping table when inserting comments.

**Expired content reappearing.** Textpattern has a native `Expires` field; WordPress does not. Articles with past expiry dates will become published posts. Before import, set `Status = 2` (draft) in your migration for any article where `Expires` is non-null and in the past, or import them as WordPress drafts and handle expiry via post meta and the PublishPress Future plugin.

**`glz_custom_fields` data missing.** If the source site used this plugin, the extra custom fields are in separate tables, not `custom_1` through `custom_10`. Query `glz_custom_fields` and `glz_field_types` explicitly.

**Presentation templates imported as content.** Textpattern stores pages, forms, and styles in `txp_page`, `txp_form`, and `txp_css`. These are presentation assets, not content rows. Rebuild them as WordPress theme templates — do not attempt to import them.

**Comment count mismatch.** WordPress caches `comment_count` in `wp_posts`. If you insert comments directly into `wp_comments` without updating this column, post comment counts will show 0. Run the `UPDATE wp_posts` query shown in the comments section after migration.

## When to DIY vs. When to Get Help

**DIY is reasonable when:**
- Your site has fewer than ~200 articles
- Custom fields aren't business-critical
- Images are minimal or externally hosted
- You're comfortable with SQL and PHP/Python scripting
- You can tolerate a few hours of downtime

**Get help when:**
- You have thousands of articles with complex Textile formatting
- Custom fields carry business data (product info, structured metadata)
- The site uses `glz_custom_fields` or other plugins that extend the default schema
- SEO is a major concern and redirect accuracy must be 100%
- You need zero-downtime cutover with delta sync
- You have multiple authors and role-based permissions to preserve

## Making It Stick

A Textpattern to WordPress migration looks simple on the surface — both platforms use MySQL, both store articles — but the devil is in the data model differences. Textile markup, flat sections, two-category limits, numeric image filenames, per-section `permlink_mode` settings, nested-set category hierarchy, and ten rigid custom fields all need explicit transformation logic.

Use the `Body_html` columns. Query `txp_prefs` for custom field labels and `permlink_mode` before you write a single line of migration code. Build your redirect map before cutover. Re-host every image and rewrite every reference. Preserve source IDs as post meta. Maintain an article ID mapping table before touching comments. Test on staging. Validate counts. Monitor for 404s after go-live.

If you have not explicitly decided what `Section`, `Category2`, `Keywords`, `Expires`, the `Image` field, and `glz_custom_fields` data become in WordPress, you are still planning. As with any [zero-downtime migration](https://clonepartner.com/blog/blog/the-ultimate-knowledge-base-migration-checklist-a-zero-downtime-plan/), you are not ready to import.

> ClonePartner handles Textpattern to WordPress migrations end to end — data extraction, markup conversion, image re-hosting, redirect mapping, and post-launch validation. Book a 30-minute call and we'll scope your migration.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can I migrate from Textpattern to WordPress automatically?

The official WordPress TextPattern Importer plugin exists but has well-documented reliability issues and very low active installs. The most dependable approach is using a WXR export script (like Drew McLellan's textpattern-to-wordpress) that generates an importable XML file from your Textpattern database. Complex sites benefit from a custom SQL-to-REST API script for full control over field mapping and error handling.

### Should I export raw Textile or HTML from Textpattern?

Export HTML. Textpattern stores both raw Textile (Body) and pre-rendered HTML (Body_html) for every article. Import Body_html directly into WordPress — it matches what visitors have been seeing and eliminates the need to parse Textile at migration time. The DrewM WXR exporter defaults to HTML output for the same reason.

### Will my Textpattern image links break in WordPress?

Yes, unless you handle media as a separate pipeline. Textpattern stores images in an /images/ folder using ID-based filenames (e.g., 1.jpg). You must copy these files, upload them to the WordPress media library, create attachment records, and rewrite all in-content references — including TXP tags, Textile shortcodes, and direct /images/ paths.

### Will I lose my SEO rankings migrating from Textpattern to WordPress?

Only if you skip 301 redirects. Textpattern typically uses /section/url_title URLs while WordPress defaults to /postname/. Build a complete redirect map before cutover that sends every old URL to its new WordPress equivalent. Monitor Google Search Console for 404 errors during the first 2–4 weeks post-migration.

### How long does a Textpattern to WordPress migration take?

A typical site with 500–2,000 articles migrates in 2–5 days, including testing. Sites with extensive custom fields, thousands of images, complex comment histories, or strict zero-downtime requirements push toward 1–2 weeks. The migration script itself runs fast — redirect mapping, image re-hosting, and validation consume the most time.
