---
title: Emscripten wheels for Pydantic
description: A complete guide on how to build and test Emscripten wheels
date: '2026-05-29'
authors:
  - Victorien Plot
categories:
  - Pydantic Validation
  - WebAssembly
canonical: 'https://pydantic.dev/articles/emscripten-wheels-pydantic'
---

> Markdown version of [Emscripten wheels for Pydantic](https://pydantic.dev/articles/emscripten-wheels-pydantic) — the canonical HTML page.
>
> By [Victorien Plot](https://pydantic.dev/authors/victorien-plot.md) · 2026-05-29 · Pydantic Validation, WebAssembly
>
> Related: [Announcement: Pydantic v2.13 Release](https://pydantic.dev/articles/pydantic-v2-13-release.md) · [Reducing Pydantic's memory footprint using bitsets](https://pydantic.dev/articles/pydantic-bitset-performance.md)
>
> All articles: [/articles.md](https://pydantic.dev/articles.md) · Site index: [/llms.txt](https://pydantic.dev/llms.txt)

---

Since Pydantic V2, the library is powered by a Rust core (called `pydantic-core`), responsible for most of the validation and serialization logic.
To make Pydantic usable across as many Python versions and platforms as possible, several [wheels](https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#wheels)
are provided for `pydantic-core`, which allows installing Pydantic without having to build the Rust component from source (which requires a Rust toolchain on the host
machine, and may or may not work depending on the machine you are using).

[PEP 783 - Emscripten Packaging](https://peps.python.org/pep-0783/) was recently accepted, meaning we can now officially publish wheels for the Pyodide CPython distribution.

The Pyodide/Python Emscripten ecosystem can be a bit confusing (and more generally the Python WebAssembly ecosystem), and the tooling to build wheels for libraries is still in its early days.
For this reason, this blog post will serve as a guide to build and test your own Pyodide wheels, and will also provide external resources to follow the progress in the ecosystem.

## Table of Contents

* [Introduction](#introduction)
  * [Emscripten and Pyodide](#emscripten-and-pyodide)
* [Adding Pyodide/Emscripten wheels to your project](#adding-pyodideemscripten-wheels-to-your-project)
  * [The PyEmscripten platform](#the-pyemscripten-platform)
  * [Building the wheel](#building-the-wheel)
  * [Testing the wheel](#testing-the-wheel)
* [Conclusion](#conclusion)

## Introduction

At its core, [WebAssembly](https://webassembly.org/) is just a binary instruction format. It is meant to be portable, and just needs an interpreter to run. Just as Python
interprets a `.py` file on the fly, a WebAssembly interpreter interprets a `.wasm` file [^1].

WebAssembly is mostly used in the web, as the core specification is extended by the [JavaScript API](https://webassembly.github.io/spec/js-api/index.html)
(describing how JavaScript objects can be accessed from a WebAssembly program) and the [Web API](https://webassembly.github.io/spec/web-api/index.html) (extending the JavaScript API
specifically for web browsers).

WebAssembly being a binary format, you can't write `.wasm` files by hand [^2]. Instead, you write your program in the language of your choice (C, Rust, etc) and use a WebAssembly compiler
to obtain your `.wasm` binary. For Rust, the compiler can compile to WebAssembly natively. For C/C++, compilers such as [Emscripten](https://emscripten.org/) can be used.

If you are writing your program in Python, you have two choices:

* Use a Python to WebAssembly compiler, such as [`py2wasm`](https://wasmer.io/posts/py2wasm-a-python-to-wasm-compiler). Doing so is pretty uncommon, and it seems that the `py2wasm` development has stalled.
* Compile the Python interpreter to WebAssembly, and use it to interpret your Python program.

The second option is by far the most common. The CPython interpreter being written in C, Emscripten can be used to compile it.

### Emscripten and Pyodide

CPython has supported Emscripten as a build target for a while now (October 2024), and support was recently formalized in [PEP 776](https://peps.python.org/pep-0776/).
Another Emscripten distribution called Pyodide also exists, providing a complete ecosystem focused on web browsers and Node.js:

* Binary releases published on [GitHub](https://github.com/pyodide/pyodide/releases) and [npm](https://www.npmjs.com/package/pyodide).
* A JavaScript interface.
* A toolchain for building, testing, and installing packages for use within the Pyodide interpreter.

Any pure Python package (i.e. libraries *not* using any native component, unlike Pydantic and its Rust core) can be installed within a Pyodide environment. However, before
[PEP 783](https://peps.python.org/pep-0783/), Pyodide had to maintain its own set of Pyodide-compatible wheels. As it is now possible to publish such wheels directly to [PyPI](https://pypi.org/),
Pyodide won't have to maintain a separate index.

## Adding Pyodide/Emscripten wheels to your project

When adding support for a build target to your project, it is recommended to also run tests for this target to ensure the wheel you will publish on PyPI actually works. In this section,
we will first see how to build the wheel inside your CI pipeline [^3], and how to set up a test workflow using the Pyodide interpreter.

### The PyEmscripten platform

[PEP 783](https://peps.python.org/pep-0783/) introduces the _PyEmscripten_ platform, which is versioned (with a year and patch number), and encapsulates the Emscripten compiler version along with
other information (linked libraries, etc.).

For example, if you want to build a wheel targeting the `pyemscripten_2026_0` platform, you will have to build it with the corresponding Emscripten version (5.0.3).

Regarding Pyodide:

* Each Pyodide version range matches a specific Python version (for example, Pyodide version 314.x.y will be the WebAssembly compiled version of the CPython 3.14 interpreter, Pyodide versions 0.28.x/0.29.x are for CPython 3.13 [^4]).
* Each Pyodide version range matches a specific _PyEmscripten_ platform (Pyodide 314.x.y targets `pyemscripten_2026_0`, 0.28.x/0.29.x targets `pyemscripten_2025_0` [^5]).

When users want to install your package in their Pyodide virtual environment, the package installer will resolve the matching _PyEmscripten_ platform tag from the current Pyodide version, and install the corresponding wheel from PyPI.

### Building the wheel

Building wheels can be a tedious process, mainly because each wheel must be built on a machine matching its target platform and architecture (Windows, ARM, x86, etc.).
The number of wheels can also grow quickly (for example, the last `pydantic-core` release [provides](https://pypi.org/project/pydantic_core/2.46.4/#files) 119 distinct wheels).
For this reason, it is strongly recommended to use build tools such as [`cibuildwheel`](https://github.com/pypa/cibuildwheel) or [`maturin`](https://github.com/PyO3/maturin)
and the [`maturin-action`](https://github.com/PyO3/maturin-action) GitHub action.

Setting up CI using these tools is usually straightforward (`maturin` even provides a [`generate-ci`](https://www.maturin.rs/distribution.html#github-actions) CLI to do this for you).
For Pyodide however, things are a bit more involved. It might be simplified in the future, and this blog post will be updated if necessary.

The [`pyodide-build`](https://pyodide-build.readthedocs.io/en/latest/index.html) documentation provides useful resources to build wheels depending on the language (Rust, C)
and the build system (`meson-python`, `setuptools`). Most of the time, the `pyodide build` command can be used as a drop in replacement for the well known
[`build`](https://build.pypa.io/en/stable/) library (and will not require manually installing the [emsdk](https://emscripten.org/docs/tools_reference/emsdk.html)).
However, in the following example, we will show how we did it for Pydantic using `maturin` directly for Rust projects:

```yaml
build-pyemscripten:
  name: Build for PyEmscripten platform (${{ matrix.python-version }})
  runs-on: ubuntu-latest
  strategy:
    fail-fast: false
    matrix:
      python-version:
        - '3.12'
        - '3.13'
        - '3.14'

    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
        with:
          python-version: ${{ matrix.python-version }}
          enable-cache: false  # (1)!

      - run: uv sync --only-group pyodide-grp  # (2)!

      - name: Get pyodide config  # (3)!
        id: pyodide-config
        run: |
          echo "rust-toolchain=$(pyodide config get rust_toolchain)" >> "$GITHUB_OUTPUT"
          echo "emscripten-version=$(pyodide config get emscripten_version)" >> "$GITHUB_OUTPUT"
          echo "pyodide-abi-version=$(uv run pyodide config get pyodide_abi_version)" >> "$GITHUB_OUTPUT"
          echo "rustflags=$(uv run pyodide config get rustflags)" >> "$GITHUB_OUTPUT"

      - uses: mymindstorm/setup-emsdk@4528d102f7230f0e7b276855c01ea1159be0e984 # v16
        with:
          version: ${{ steps.pyodide-config.outputs.emscripten-version }}
          no-cache: true  # (4)!

      - name: Build wheels
        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1
        env:
          CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_RUSTFLAGS: ${{ steps.pyodide-config.outputs.rustflags }}
          MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION: ${{ steps.pyodide-config.outputs.pyodide-abi-version }}  # (5)!
        with:
          target: wasm32-unknown-emscripten
          args: --release --out dist --interpreter ${{ matrix.python-version }}
          rust-toolchain: ${{ steps.pyodide-config.outputs.rust-toolchain }}

      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: pyemscripten_wheels_${{ matrix.python-version }}
          path: dist
```

1. If the built wheel is reused for publishing, you may want to disable cache [for security reasons](https://docs.zizmor.sh/audits/#cache-poisoning).
2. This is assuming you have a `pyodide-grp` [dependency group](https://packaging.python.org/en/latest/specifications/dependency-groups/) with `pyodide-build` as a dependency.
   You can adapt this part to match your package manager of choice (or simply run `pip install pyodide-build`), but we recommend installing from your lockfile for
   security!
3. The `pyodide-build` CLI is used to resolve the matching Emscripten and Rust version to use when building wheels. The CLI determines these two pieces of information based on the current
   Python version (3.13 or 3.14). For example, with 3.14, it will assume the wheel will target Pyodide 314.x.y, which targets `pyemscripten_2026_0`, which in turn targets Emscripten
   5.0.3. Similarly, a specific Rust version is required and depends on the target Python version.
4. Again, if the built wheel is reused for publishing, consider disabling the cache.
5. With the last Maturin release available today (v1.13.3), the old `pyodide_2024/5_0` platform tag for Python 3.12 and 3.13 is used, which will _not_ be accepted by PyPI.
   The correct platform can be provided with this environment variable. Things are a bit unclear at the moment, see [this issue](https://github.com/PyO3/maturin/issues/3189).

Note that each Python version target requires different Rust versions. For instance, the Rust toolchain provided by the Pyodide CLI under 3.13 is `nightly-2025-02-01`
(targeting Rust 1.86.0). If this doesn't match your <abbr title="Minimum Supported Rust Version">MSRV</abbr>, you may have to use a newer nightly
(this [repository](https://github.com/oxalica/rust-overlay/tree/master/manifests/nightly) can be used to find the Rust version of each nightly release).
You will possibly encounter failures in doing so, and it might require adapting the Rust flags being used.

### Testing the wheel

Things are also a bit tricky when it comes to testing the wheel, in particular when installing dependencies. We'll use the Pyodide CLI to create a Pyodide virtual environment.
This environment will use a patched version of `pip`, which will also look for wheels in the Pyodide index. Here is an example workflow:

```yaml
test-pyemscripten:
  name: Test for PyEmscripten platform (${{ matrix.python-version }})
  needs: [build-pyemscripten]
  runs-on: ubuntu-latest
  strategy:
    fail-fast: false
    matrix:
    include:
      - python-version: '3.12'
        pylock-suffix: '312'
      - python-version: '3.13'
        pylock-suffix: '313'
      - python-version: '3.14'
        pylock-suffix: '314'

  steps:
    - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      with:
        persist-credentials: false

    - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
      with:
        python-version: ${{ matrix.python-version }}
        activate-environment: true

    - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
      with:
        name: pyemscripten_wheels_${{ matrix.python-version }}
        path: dist

    - run: |
        uv pip install pyodide-build
        uv run pyodide venv .venv-pyodide
        source .venv-pyodide/bin/activate
        python -m pip install -r .github/pyodide/pylock.${{ matrix.pylock-suffix }}.toml
        python -m pip install <your_package_name> --no-index --no-deps --find-links dist
        python -m pytest
```

Using `pyodide venv` will create a virtual environment with a Pyodide version matching the Python version under which the command was run. To install your test dependencies, you have a few options:

* Use your package manager to sync the dependencies. This may or may not work, depending on the package manager and the dependencies you are using [^6], and you may have to
  configure it to use the Pyodide extra index (available in `.venv-pyodide/pip.conf`).
* Use a [`pylock.toml`](https://packaging.python.org/en/latest/specifications/pylock-toml/) lockfile, produced from a curated list of dependencies (you may have to exclude
  some of your existing test dependencies that aren't compatible with the PyEmscripten platform, such as `pytest-timeout`). Write these dependencies in a `requirements.in`
  file, and run `pip lock -r requirements.in -o pylock.31x.toml` **from a Pyodide environment**, for each Python version you want to test on (this is necessary as `pylock.toml`
  files _aren't_ universal).

We realize this process is pretty convoluted and we hope to simplify the whole process by using uv. uv seems to be able to install Pyodide (although with some
[existing issues](https://github.com/astral-sh/uv/issues/16023)), and there is [ongoing work](https://github.com/astral-sh/uv/pull/15893) to support the Pyodide index.

You will likely encounter test failures on the initial run. Several Python features aren't supported in Pyodide (e.g. using threading or multiprocessing; see the complete list
[here](https://pyodide.org/en/latest/usage/wasm-constraints.html#pyodide-python-compatibility)). You can conditionally skip tests using a `sys.platform == 'emscripten'` check.

## Conclusion

Having [PEP 776](https://peps.python.org/pep-0776/) and [PEP 783](https://peps.python.org/pep-0783/) accepted was a big step forward for WebAssembly support in Python.
There is also ongoing work to support [WASI](https://wasi.dev/) in CPython (see [PEP 816](https://peps.python.org/pep-0816/) and this
[blog post](https://snarky.ca/state-of-wasi-support-for-cpython-march-2026/)).

Pydantic [v2.14.0a1](https://github.com/pydantic/pydantic/releases/tag/v2.14.0a1) was just released, and includes a [PyEmscripten wheel](https://pypi.org/project/pydantic_core/#pydantic_core-2.47.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl).

If you want to experiment with Pyodide in the web, check out this [interactive console](https://pyodide.org/en/latest/console-v2.html) (Pydantic included)!

### Footnotes

[^1]: The only difference being that `.py` files are (human-readable) text files, although in reality, the Python interpreter compiles the input source code to
      [bytecode](https://docs.python.org/3/glossary.html#term-bytecode), that in turn is directly interpreted. Such compilation is usually
      [cached](https://docs.python.org/3/tutorial/modules.html#compiled-python-files), and this is why you sometimes come across `.pyc` files: these are the compiled
      version of your source code.

[^2]: Actually you can, by using the [WebAssembly text format](https://developer.mozilla.org/en-US/docs/WebAssembly/Guides/Understanding_the_text_format) (`.wat` files), although
      it is questionable whether you should do this.

[^3]: Using GitHub Actions.

[^4]: The versioning scheme recently changed, to better indicate the CPython version.

[^5]: Before being properly formalized, the platform tag was `pyodide_{YEAR}_{PATCH}`. PyPI will only accept the new `pyemscripten_{YEAR}_{PATCH}` tag.

[^6]: I am not sure `uv` has full support for Pyodide environments as of today.
