Run Python in a single HTML file with Pyodide

Pyodide is a Python distribution for the browser, built on WebAssembly (WASM). One script tag and a few lines of JavaScript run Python code inside your page.

Pyodide is a Python distribution for the browser, built on WebAssembly. Here, WASM means WebAssembly: Pyodide is a port of CPython, the standard Python, to it. It is not a hosting service.

Add one script tag to an HTML page and loadPyodide() gives you a Python you can call from JavaScript, running in the visitor's tab.

This is the smallest page that works. Save it as an .html file, or paste it into a NOS document:

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<pre id="out">Loading Python...</pre>
<script src="https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide.js"></script>
<script>
  async function main() {
    const pyodide = await loadPyodide();
    document.getElementById('out').textContent = pyodide.runPython("sum(range(1, 11))");
  }
  main();
</script>

It prints 55. The next example adds a text box, a Run button, and a place for print() output and errors. Edit the Python and press Run.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pyodide: run Python in a page</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  h1 { font-size: 16px; margin: 0 0 8px; }
  #status { font-size: 13px; color: #6b7280; margin: 0 0 8px; }
  #status.ready { color: #0f5132; }
  textarea {
    width: 100%; height: 130px; padding: 10px; border: 1px solid #d5d9e0; border-radius: 8px;
    font: 13px/1.45 ui-monospace, Consolas, monospace; resize: vertical;
  }
  button {
    margin: 8px 0; padding: 9px 18px; border: 0; border-radius: 8px;
    background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer;
  }
  button:disabled { background: #a8b0bd; cursor: default; }
  pre {
    margin: 0; min-height: 90px; padding: 10px; border-radius: 8px; background: #111827; color: #e5e7eb;
    font: 13px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere;
  }
  pre.err { color: #fca5a5; }
</style>
</head>
<body>
<h1>Python in this page</h1>
<p id="status">Loading Python (first load downloads the runtime)...</p>
<textarea id="code" spellcheck="false">name = "NOS"
squares = [n * n for n in range(1, 6)]
print("Hello from Python,", name)
print("squares:", squares)
sum(squares)</textarea>
<button id="run" disabled>Run</button>
<pre id="out">Output appears here.</pre>

<!-- pinned version: the number in the URL is the Pyodide version -->
<script src="https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide.js"></script>
<script>
  const status = document.getElementById('status');
  const out = document.getElementById('out');
  const runBtn = document.getElementById('run');
  let pyodide;

  // print() in Python calls these two functions, one line at a time
  function show(line) { out.textContent += line + '\n'; }

  async function start() {
    try {
      pyodide = await loadPyodide({ stdout: show, stderr: show });  // returns a Promise
      status.textContent = 'Pyodide ' + pyodide.version + ' is ready.';
      status.classList.add('ready');
      runBtn.disabled = false;
    } catch (err) {
      status.textContent = 'Could not load Pyodide: ' + err.message;
    }
  }

  runBtn.addEventListener('click', () => {
    out.textContent = '';
    out.classList.remove('err');
    try {
      const result = pyodide.runPython(document.getElementById('code').value);
      // runPython returns the value of the last line if it is an expression
      if (result !== undefined) show('=> ' + result);
    } catch (err) {
      out.classList.add('err');
      show(err.message);  // a Python error arrives as a JavaScript exception
    }
  });

  start();
</script>
</body>
</html>
Type any Python and press Run. Output and errors appear below. The first load downloads the runtime, so the button is disabled until it is ready.

What the page does, step by step

There are four moments, and only one of them is slow.

The four steps from script tag to result. The orange step is the one the page has to wait for.
The four steps from script tag to result. The orange step is the one the page has to wait for.
  1. Add the script tag. pyodide.js comes from the jsDelivr CDN. The number in the URL is the Pyodide version, so the page keeps using that release.
  2. Await loadPyodide(). It fetches and starts the runtime. It returns a Promise, so use await inside an async function. See async and await in JavaScript if that is new.
  3. Call runPython(). It takes a string of Python. When the last line is an expression, its value comes back to JavaScript.
  4. Put the result in the page. Set textContent on an element, as the example does.

Wait for the runtime before running Python

An easy mistake is calling runPython right after loadPyodide() without waiting. loadPyodide() returns a Promise, so what you hold is not the runtime yet.

Without await you hold a Promise, which has no runPython. With await you hold the ready runtime.
Without await you hold a Promise, which has no runPython. With await you hold the ready runtime.

Keep the Run button disabled until await loadPyodide() has finished, then enable it. The demo above does exactly that, and it also shows a message if the download fails, for example when the device is offline.

Show print output and errors on the page

Python's print() does not write into your HTML by itself. Pyodide sends it to a function you can replace with the stdout option, and error text goes to stderr:

const pyodide = await loadPyodide({
  stdout: (line) => { out.textContent += line + '\n'; },
  stderr: (line) => { out.textContent += line + '\n'; },
});

A Python exception arrives in JavaScript as a PythonError, which is a JavaScript error caused by a Python exception. Wrap runPython in try and catch and show err.message. Try print(1/0) in the first demo to see it.

Packages: standard library, loadPackage, micropip

Python's standard library is available, with exceptions such as threads and sockets. Packages that are not in the standard library come from two places.

Three ways to get Python code: import it, load a Pyodide package, or install a pure Python wheel.
Three ways to get Python code: import it, load a Pyodide package, or install a pure Python wheel.

pyodide.loadPackage() loads packages from the Pyodide distribution, and downloads them from the jsDelivr CDN. It returns a Promise, so await it. micropip installs pure Python wheels from PyPI. It is itself a package you load first:

await pyodide.loadPackage('micropip');
await pyodide.runPythonAsync(
  "import micropip\nawait micropip.install('six')"
);

runPythonAsync is the version of runPython that allows top-level await inside the Python code. The next demo computes the same statistics two ways, so you can see the extra download that numpy needs.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pyodide: standard library vs loadPackage</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  h1 { font-size: 16px; margin: 0 0 8px; }
  #status { font-size: 13px; color: #6b7280; margin: 0 0 8px; }
  #status.ready { color: #0f5132; }
  label { font-size: 13px; font-weight: 600; }
  input[type=text] {
    display: block; width: 100%; margin: 4px 0 10px; padding: 9px 10px;
    border: 1px solid #d5d9e0; border-radius: 8px; font-size: 14px;
  }
  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  .box { border: 1px solid #e1e4ea; border-radius: 10px; background: #fff; padding: 10px; }
  .box h2 { font-size: 13px; margin: 0 0 4px; }
  .box p { font-size: 12px; color: #4b5563; margin: 0 0 8px; line-height: 1.4; }
  button {
    width: 100%; padding: 9px 8px; border: 0; border-radius: 8px;
    background: #2563eb; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
  }
  button:disabled { background: #a8b0bd; cursor: default; }
  .res { min-height: 66px; margin: 8px 0 0; font: 12.5px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
  @media (max-width: 480px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>Standard library or a package?</h1>
<p id="status">Loading Python...</p>

<label for="nums">Numbers, separated by commas</label>
<input type="text" id="nums" value="3, 5, 8, 13, 21, 34">

<div class="row">
  <div class="box">
    <h2>statistics (built in)</h2>
    <p>Part of the Python standard library. Nothing more to download.</p>
    <button id="std" disabled>Compute</button>
    <div class="res" id="r1"></div>
  </div>
  <div class="box">
    <h2>numpy (a package)</h2>
    <p>loadPackage downloads it the first time you press the button.</p>
    <button id="np" disabled>Load numpy and compute</button>
    <div class="res" id="r2"></div>
  </div>
</div>

<script src="https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide.js"></script>
<script>
  const $ = (id) => document.getElementById(id);
  let pyodide;

  async function start() {
    try {
      pyodide = await loadPyodide();
      $('status').textContent = 'Pyodide ' + pyodide.version + ' is ready.';
      $('status').classList.add('ready');
      $('std').disabled = false;
      $('np').disabled = false;
    } catch (err) {
      $('status').textContent = 'Could not load Pyodide: ' + err.message;
    }
  }

  // hand the text from the page to Python as a global variable
  function pass() { pyodide.globals.set('raw', $('nums').value); }

  $('std').addEventListener('click', () => {
    pass();
    const t = performance.now();
    try {
      $('r1').textContent = pyodide.runPython(`
import statistics
xs = [float(s) for s in raw.split(",")]
f"mean {statistics.mean(xs):.2f}\\nstdev {statistics.stdev(xs):.2f}"
`) + '\n' + Math.round(performance.now() - t) + ' ms';
    } catch (err) { $('r1').textContent = err.message.split('\n').slice(-2).join('\n'); }
  });

  $('np').addEventListener('click', async () => {
    pass();
    const t = performance.now();
    $('r2').textContent = 'Downloading numpy...';
    try {
      await pyodide.loadPackage('numpy');  // returns a Promise: wait for it
      $('r2').textContent = pyodide.runPython(`
import numpy as np
a = np.array([float(s) for s in raw.split(",")])
f"mean {a.mean():.2f}\\nstd {a.std(ddof=1):.2f}"
`) + '\n' + Math.round(performance.now() - t) + ' ms';
    } catch (err) { $('r2').textContent = err.message.split('\n').slice(-2).join('\n'); }
  });

  start();
</script>
</body>
</html>
Left: statistics from the standard library runs at once. Right: numpy is downloaded the first time you press the button. The page measures and shows each time.

To pass data from the page into Python, use pyodide.globals.set('name', value). To read a Python variable back, use pyodide.globals.get('name'). Text and numbers convert to the matching JavaScript types.

What does not work in a browser

Pyodide runs inside the browser's rules, so a few things behave differently from Python on a computer:

  • Threads. The Pyodide docs say threading and multiprocessing are not supported.
  • Network. All network calls go through the browser, so CORS applies. Read CORS explained for what that means.
  • Long calculations. The docs warn that long synchronous computations in the main thread block the UI. The fix is a web worker, which must be a module worker because pyodide.asm.mjs is an ES module. See web workers in JavaScript. A worker needs its own script file, so it is outside what one pasted page can show here.
  • Self-hosted files. If you host the Pyodide files yourself, the server must set the WASM MIME type and CORS headers. This article uses the CDN, so there is nothing to host.

A finished example: a word counter

The same pieces make a small tool. The Python lives inside a <script type="text/python"> tag.

A script whose type is not a JavaScript type is a data block that the browser does not run, so it is a tidy place to keep the code. The page reads it with textContent and hands it to runPython.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Word counter powered by Python</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  h1 { font-size: 16px; margin: 0 0 4px; }
  #status { font-size: 13px; color: #6b7280; margin: 0 0 10px; }
  #status.ready { color: #0f5132; }
  textarea {
    width: 100%; height: 110px; padding: 10px; border: 1px solid #d5d9e0; border-radius: 8px;
    font: 14px/1.45 system-ui, sans-serif; resize: vertical;
  }
  .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 10px 0; }
  .card { background: #fff; border: 1px solid #e1e4ea; border-radius: 10px; padding: 8px 10px; }
  .card b { display: block; font-size: 20px; }
  .card span { font-size: 12px; color: #6b7280; }
  .bar { display: grid; grid-template-columns: 84px 1fr 28px; gap: 8px; align-items: center; font-size: 13px; margin: 5px 0; }
  .bar .w { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .bar .track { height: 12px; border-radius: 6px; background: #e5e7eb; overflow: hidden; }
  .bar .fill { height: 100%; background: #2563eb; border-radius: 6px; }
  .bar .n { text-align: right; color: #6b7280; }
  details { margin-top: 10px; font-size: 13px; }
  pre { margin: 6px 0 0; padding: 10px; border-radius: 8px; background: #111827; color: #e5e7eb; font: 12px/1.45 ui-monospace, Consolas, monospace; overflow-x: auto; }
</style>
</head>
<body>
<h1>Word counter, computed by Python</h1>
<p id="status">Loading Python...</p>
<textarea id="text" spellcheck="false">The quick brown fox jumps over the lazy dog. The dog sleeps, the fox runs, and the quick fox wins.</textarea>

<div class="cards">
  <div class="card"><b id="words">-</b><span>words</span></div>
  <div class="card"><b id="uniq">-</b><span>different words</span></div>
  <div class="card"><b id="avg">-</b><span>letters per word</span></div>
</div>
<div id="bars"></div>

<details>
  <summary>The Python behind it</summary>
  <pre id="show"></pre>
</details>

<!-- a script with an unknown type is not run by the browser: it is just a safe place to keep Python -->
<script type="text/python" id="py">
import json, re
from collections import Counter

words = re.findall(r"[a-z']+", text.lower())
top = Counter(words).most_common(6)
result = json.dumps({
    "words": len(words),
    "unique": len(set(words)),
    "avg": round(sum(map(len, words)) / len(words), 1) if words else 0,
    "top": top,
})
</script>

<script src="https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide.js"></script>
<script>
  const $ = (id) => document.getElementById(id);
  const code = $('py').textContent;
  $('show').textContent = code.trim();
  let pyodide;

  function analyse() {
    if (!pyodide) return;
    pyodide.globals.set('text', $('text').value);  // page -> Python
    pyodide.runPython(code);
    const r = JSON.parse(pyodide.globals.get('result'));  // Python -> page, as JSON text
    $('words').textContent = r.words;
    $('uniq').textContent = r.unique;
    $('avg').textContent = r.avg;
    const max = r.top.length ? r.top[0][1] : 1;
    $('bars').innerHTML = '';
    for (const [word, n] of r.top) {
      const row = document.createElement('div');
      row.className = 'bar';
      row.innerHTML = '<span class="w"></span><span class="track"><span class="fill"></span></span><span class="n"></span>';
      row.children[0].textContent = word;      // textContent: user text never becomes HTML
      row.children[1].firstChild.style.width = (n / max * 100) + '%';
      row.children[2].textContent = n;
      $('bars').appendChild(row);
    }
  }

  async function start() {
    try {
      pyodide = await loadPyodide();
      $('status').textContent = 'Pyodide ' + pyodide.version + ' is ready. Edit the text.';
      $('status').classList.add('ready');
      analyse();
    } catch (err) {
      $('status').textContent = 'Could not load Pyodide: ' + err.message;
    }
  }

  $('text').addEventListener('input', analyse);
  start();
</script>
</body>
</html>
Edit the text. Python counts the words and the six most common ones, and the page draws the bars. Open the details to see the Python.
  • Page to Python: globals.set('text', value) before running.
  • Python to page: the Python builds a JSON string, and the page reads it with globals.get and JSON.parse.
  • Safe display: the page writes words with textContent, so text typed into the box is never read as HTML.

When it does not work

What you see Cause Fix
loadPyodide is not defined The script tag is missing, failed to load, or comes after your code Put the pyodide.js tag before your script. Check the URL
runPython is not a function loadPyodide() was not awaited await it in an async function
print() shows nothing on the page Output goes to a callback, not the DOM Pass stdout and stderr to loadPyodide
ModuleNotFoundError for numpy The package was never loaded Call loadPackage('numpy') and await it first
The page freezes during a long loop Python runs on the main thread Move it into a module web worker
RuntimeError with threads Threading is not supported Remove threads from the code
micropip fails on a URL That server does not send CORS headers Use a host that does, or a PyPI package
The first run is slow The runtime has to download Show a loading message and disable Run

A Python page is hard to describe and easy to try. The reader can type their own code or text and watch Python answer, which a screenshot cannot show.

On a phone, the viewport meta tag keeps the page sized right, and opening an HTML file on a phone covers why attachments are awkward.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, and pyodide.js comes from jsDelivr, one of the CDN hosts a NOS page can load scripts from. People with the link can open it without an account and run the code themselves.

If you change the code later, the same link shows the new version.

Questions people ask

Is Pyodide the same as running Python on a server?

No. The Pyodide project describes itself as a Python distribution for the browser and Node.js based on WebAssembly. The Python code runs in the visitor's browser tab, so the page needs no Python server of its own.

Do I need npm or a build step?

No. The quickstart loads pyodide.js with a plain script tag from the jsDelivr CDN. After that, loadPyodide() is available to the scripts that come after the tag.

Can I use numpy, pandas or other packages?

Packages that ship in the Pyodide distribution load with pyodide.loadPackage(). The demo above does this with numpy. Each Pyodide version publishes its package list in a file named pyodide-lock.json next to pyodide.js. Pure Python wheels from PyPI can be installed with micropip.

Can Python in the page make network requests?

Yes, but through the browser. The Pyodide docs say all network calls are done via the browser, so the same limits as JavaScript apply, including CORS. A server that does not send the right headers will be blocked.

Which license does Pyodide use?

The Pyodide repository on GitHub lists the Mozilla Public License 2.0 (MPL-2.0). Read the license text itself before you build something that redistributes Pyodide files.

Keep reading