DEV Community

Priya Sundaram
Priya Sundaram

Posted on

Whoosh on the no-GIL Python: pure-Python search that can finally use your cores

For fifteen years, the standard answer to "why is my pure-Python search slow to
index?" was: the GIL. Tokenizing, stemming, and building postings is all
CPU-bound Python, and only one thread gets to run Python at a time. Threads
didn't help. If you wanted parallelism you dropped into a C extension or shelled
out to a separate process.

Python 3.14 (stable since October 2025) ships an officially supported
free-threaded build — the no-GIL build from [PEP 703]. On it, pure-Python
CPU work can actually run on multiple cores at once. That is unusually good news
for a pure-Python full-text search library like
Whoosh (pip install whoosh3,
the maintained fork), because indexing is exactly the kind of per-token Python
work that used to be stuck behind the GIL.

But "no GIL" is not a magic @parallel decorator. You still have to structure
the work so threads don't stomp on each other. This post is the concrete
pattern — the one shipped as a worked example in the repo — for indexing a
corpus across threads safely, plus the honest performance caveats.

The one rule that makes it safe: one writer per thread

Whoosh's concurrency contract is small enough to memorize:

Object Thread-safety
Built Schema Shareable — immutable once built
Index handle Shareable
IndexReader / Searcher One per thread (cheap to open)
plain IndexWriter Single-writer — never shared; holds a write lock

The writer is the sharp edge. A plain IndexWriter takes the index's write
lock and is not meant to be touched by two threads at once. So the trick is
don't share a writer. Give every worker thread its own sub-index in its
own directory, then merge the finished sub-indexes at the end. No shared
writer, no lock contention — the parallelism lives entirely in the fan-out.

Fan-out / fan-in

corpus  ->  split into N shards
        ->  N worker threads, each builds its OWN sub-index   (parallel, CPU-bound)
        ->  main thread merges the sub-indexes with add_reader()
        ->  one final, ordinary Whoosh index
Enter fullscreen mode Exit fullscreen mode

The merge step uses writer.add_reader() — the same primitive Whoosh's own
multiprocessing writer uses to stitch segments together. It takes a read-only
reader from each shard, so again, no writer is ever shared.

The code

A worker builds one shard into its own directory. Only that thread ever touches
that writer, so the single-writer contract holds automatically:

from whoosh import index
from whoosh.analysis import StemmingAnalyzer
from whoosh.fields import ID, TEXT, Schema

def make_schema():
    # StemmingAnalyzer gives each thread genuine per-token CPU work —
    # exactly what free-threaded builds let you parallelize.
    return Schema(id=ID(stored=True, unique=True),
                  body=TEXT(analyzer=StemmingAnalyzer()))

def build_shard(schema, shard, subdir):
    ix = index.create_in(subdir, schema)   # this thread's own index
    w = ix.writer(limitmb=128)
    for doc_id, body in shard:
        w.add_document(id=doc_id, body=body)
    w.commit()
    ix.close()
    return subdir
Enter fullscreen mode Exit fullscreen mode

Fan out with a thread pool, then fan in with add_reader:

from concurrent.futures import ThreadPoolExecutor

def build_parallel(docs, workdir, workers):
    schema = make_schema()                      # immutable -> shared safely
    shards = [docs[i::workers] for i in range(workers)]
    subdirs = [f"{workdir}/shard-{i}" for i in range(workers)]

    with ThreadPoolExecutor(max_workers=workers) as pool:
        built = [f.result() for f in (
            pool.submit(build_shard, schema, shards[i], subdirs[i])
            for i in range(workers))]

    # Merge: one read-only reader per shard, no writer shared.
    final = index.create_in(f"{workdir}/final", schema)
    w = final.writer(limitmb=256)
    for subdir in built:
        sub = index.open_dir(subdir)
        with sub.reader() as r:
            w.add_reader(r)
        sub.close()
    w.commit(optimize=True)
    final.close()
Enter fullscreen mode Exit fullscreen mode

That's the whole pattern. The result is an ordinary Whoosh index — you search it
exactly as you would a serially-built one.

Correctness first: it's the same index either way

Parallelism that returns different results isn't a feature, it's a bug. The
merged index must be identical in behavior to a serially-built one — same doc
count, same query results. The shipped example pins that as a test:

def verify_equivalent(docs, final_dir):
    ix = index.open_dir(final_dir)
    with ix.searcher() as s:
        assert s.doc_count() == len(docs)
        # every query returns the same hits a serial build would
    ix.close()
Enter fullscreen mode Exit fullscreen mode

The library's test suite runs this across even and uneven shard splits, so the
fan-out/fan-in never silently drops or duplicates a document.

Now the honest part about speed

Run the example
and it prints your build's GIL status via sys._is_gil_enabled(), times a
serial baseline against the parallel path, and tells you what to expect:

  • On a normal (GIL) build, the parallel path is not faster — often a touch slower, because you pay the merge overhead without getting real parallelism. The GIL still serializes the per-token Python work. The example says so out loud rather than pretending otherwise.
  • On a free-threaded build (3.13t/3.14t, GIL disabled), that same pure-Python indexing work is free to spread across cores. That is where the fan-out pulls ahead — and crucially, without a C extension releasing the GIL for you. The exact speedup depends on your cores, corpus, and analyzer chain, so measure it on your own hardware with the built-in harness rather than trusting a headline number.

The point isn't a magic multiplier. It's that a pure-Python library now has a
real, safe path to multi-core indexing — something that simply wasn't possible
while the GIL was mandatory.

Why this matters for pure Python specifically

Free-threading is a bigger deal for pure-Python libraries than for ones already
built on C extensions. A C-heavy library could always release the GIL around its
hot loop; a pure-Python one couldn't. So the no-GIL builds close a gap that hit
pure-Python code hardest. For a search engine you can pip install with zero
compiled dependencies, "indexing scales across cores" moves from impossible to a
documented pattern.

Whoosh's CI already runs the core test suite on the free-threaded 3.14t build
(gating) and 3.15t (early-warning) under a parallel test runner, specifically
to catch data races the GIL used to hide. The concurrency contract above is
documented in full,
and the parallel-indexing example ships in the repo so you can run it yourself.


Whoosh is a pure-Python full-text search library — no server, no compiled
extensions, no external services. I maintain the whoosh3 fork on PyPI. If the
free-threading direction is interesting to you, the
repo is where the work happens —
stars, issues, and PRs all welcome.

Top comments (0)