/Pydantic Validation

Emscripten wheels for Pydantic

12 mins

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 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 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.

At its core, WebAssembly 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 (describing how JavaScript objects can be accessed from a WebAssembly program) and the Web API (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 can be used.

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

  • Use a Python to WebAssembly compiler, such as py2wasm. 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.

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

  • Binary releases published on GitHub and npm.
  • 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, Pyodide had to maintain its own set of Pyodide-compatible wheels. As it is now possible to publish such wheels directly to PyPI, Pyodide won't have to maintain a separate index.

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.

PEP 783 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 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 119 distinct wheels). For this reason, it is strongly recommended to use build tools such as cibuildwheel or maturin and the maturin-action GitHub action.

Setting up CI using these tools is usually straightforward (maturin even provides a generate-ci 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 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 library (and will not require manually installing the emsdk). However, in the following example, we will show how we did it for Pydantic using maturin directly for Rust projects:

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.
  2. This is assuming you have a pyodide-grp dependency group 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.

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 MSRV, you may have to use a newer nightly (this repository 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.

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:

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 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), and there is ongoing work 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). You can conditionally skip tests using a sys.platform == 'emscripten' check.

Having PEP 776 and PEP 783 accepted was a big step forward for WebAssembly support in Python. There is also ongoing work to support WASI in CPython (see PEP 816 and this blog post).

Pydantic v2.14.0a1 was just released, and includes a PyEmscripten wheel.

If you want to experiment with Pyodide in the web, check out this interactive console (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, that in turn is directly interpreted. Such compilation is usually cached, 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 (.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.