Python Module 21 – Python everywhere: data, web, AI and next steps
MicroPython and Python on microcontrollers
See what MicroPython keeps from Python and what it leaves out, write logic that runs on it and on CPython, test it with fake pins and plan a board project.
What you will learn
- Explain what MicroPython supports and what it leaves out
- Write logic that runs on both MicroPython and CPython
- Test hardware logic on a computer with fake pins and a fake clock
- Plan a first hardware project, from board to deployed main.py
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what MicroPython supports and what it leaves out
- Write logic that runs on both MicroPython and CPython
- Test hardware logic on a computer with fake pins and a fake clock
- Plan a first hardware project, from board to deployed main.py
Before you start
On this page
MicroPython is a separate, compact implementation of Python 3 written for microcontrollers, the small chips inside
sensors, toys and home gadgets. According to its documentation, it covers the language of Python 3.4 plus a selection
of features from later versions, with a smaller standard library and a machine module that reaches the hardware: pins, timers, PWM, I2C
and SPI. On a board such as a Raspberry Pi Pico (the RP2 port) or an ESP32, you type into a Python prompt over USB and
make an LED blink in three lines. Most of what you know carries over. What changes is the setting: a few hundred
kilobytes of memory instead of gigabytes, documented differences from CPython, and code that talks to wires.
This lesson shows which differences matter, how to write logic that runs on both MicroPython and CPython, and how to test it on a computer with a fake pin and a fake clock before it ever touches a board. The examples run on CPython, the Python of this track and of this site’s in-browser runner, which is not MicroPython.
What MicroPython keeps and what it leaves out
The core language is there: functions, classes, exceptions, generators, comprehensions, with, async and await,
and most built-in types. The standard library is “micro-ified”: the documentation says most modules implement a
subset of the equivalent Python module, and that a port for a particular board may include only some of them; on
many boards help("modules") at the prompt lists what is there. Packages come from micropython-lib through the mip
tool, not from PyPI through pip.
The documentation also keeps a list of differences from CPython, each with a short program and both outputs. This program runs four of them and prints what happened:
"""Four things CPython does that MicroPython's list of differences says it does not. Run it on both and compare."""
import gc
import sys
print("running on", sys.implementation.name)
def attribute_on_a_function():
def handler():
pass
handler.calls = 1
return handler.calls
class Tracker:
deleted = []
def __del__(self):
Tracker.deleted.append("cleaned up")
def finaliser_runs():
t = Tracker()
del t
gc.collect()
return Tracker.deleted
class Account:
def __init__(self):
self.__pin = 1234 # CPython stores this as _Account__pin
def private_name_mangled():
return hasattr(Account(), "_Account__pin")
def locals_sees_names():
value = 2
return "value" in locals()
for name, check in [
("attribute on a function", attribute_on_a_function),
("__del__ is called", finaliser_runs),
("private name mangled", private_name_mangled),
("locals() lists local names", locals_sees_names),
]:
try:
print(name + ":", check())
except Exception as error:
print(name + ":", type(error).__name__, error) Output
running on cpython attribute on a function: 1 __del__ is called: ['cleaned up'] private name mangled: True locals() lists local names: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 probe.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
On CPython all four checks pass. MicroPython’s list of differences documents the opposite for each: user-defined
attributes on functions are not supported, __del__ is not implemented for your own classes, the compiler does not
mangle private names such as self.__pin, and locals() does not include a function’s local variables. The same file
on a board would therefore print an AttributeError, an empty list, False and False. The lesson for portable code
follows directly: keep data in dictionaries rather than on function objects, close resources with with or an explicit
close() rather than __del__, and do not rely on name mangling or locals().
| Criterion | CPython | MicroPython |
|---|---|---|
| Language | The current Python version | Python 3.4 plus selected later features |
| Standard library | Complete | Subsets of common modules; varies by board |
| Packages | PyPI, with pip or uv | micropython-lib, with mip or mpremote mip |
| Memory | Gigabytes on a computer | Kilobytes to a few megabytes on a board |
| Hardware access | Through the operating system | machine.Pin, PWM, I2C, SPI, timers |
| When to choose | Programs on computers and servers | Code that runs on a microcontroller |
Hardware through the machine module
On a board, machine.Pin controls one general-purpose input/output pin. Pin(15, Pin.OUT) makes pin 15 an output,
and on(), off() or value(1) set its level; Pin(14, Pin.IN, Pin.PULL_UP) makes an input with the internal
pull-up resistor enabled, and value() reads it as 0 or 1. Pin numbers and names depend on the board, which is why
each port has a quick reference page. The time module adds functions that CPython does not have, such as
sleep_ms(), ticks_ms() and ticks_diff(). None of this exists on a computer: CPython has no machine module, so
the import fails with ImportError, and that is the hook for testing.
Logic that runs on both
The trick that makes hardware code testable is to keep the logic apart from the hardware: functions that
receive a pin and a sleep function instead of creating them. On the board you pass a real machine.Pin and
time.sleep_ms; in tests on a computer you pass a fake pin that records what it was told and a fake clock that moves
forward without waiting:
"""Blink logic that runs unchanged on MicroPython and CPython, because the pin and the sleep are passed in."""
try:
from machine import Pin # on a board
except ImportError:
Pin = None # on CPython, and in this site's runner: no hardware
class FakeClock:
"""Pretend time for tests: sleeping only moves the counter."""
def __init__(self):
self.now = 0
def sleep_ms(self, ms):
self.now += ms
class FakePin:
"""Stands in for machine.Pin: remembers every level it is set to, and when."""
def __init__(self, clock):
self.clock = clock
self.level = 0
self.history = []
def value(self, level=None):
if level is None:
return self.level
self.level = 1 if level else 0
self.history.append((self.clock.now, self.level))
def on(self):
self.value(1)
def off(self):
self.value(0)
def blink(pin, sleep_ms, times, on_ms=200, off_ms=300):
for _ in range(times):
pin.on()
sleep_ms(on_ms)
pin.off()
sleep_ms(off_ms)
clock = FakeClock()
pin = FakePin(clock)
blink(pin, clock.sleep_ms, times=3)
print("levels set:", pin.history)
print("total:", clock.now, "ms")
assert pin.history == [(0, 1), (200, 0), (500, 1), (700, 0), (1000, 1), (1200, 0)]
print("schedule as expected; machine.Pin available:", Pin is not None)
# On a board the same function drives a real pin:
# import time
# blink(Pin(15, Pin.OUT), time.sleep_ms, times=3) Output
levels set: [(0, 1), (200, 0), (500, 1), (700, 0), (1000, 1), (1200, 0)] total: 1500 ms schedule as expected; machine.Pin available: False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 blink.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
blink() never imports machine, so it runs anywhere. The fake clock made 1.5 seconds of blinking take no real time,
and the test checks the exact moment of every change. The try/except ImportError at the top lets one file serve
both worlds: it uses the real Pin when it exists and works without it otherwise. The same file runs unchanged on
MicroPython, because it uses only classes, lists, tuples and assert.
One logic module, tested on a computer and used on the board
Text description of the diagram
The diagram shows one module used in two places.
- At the top is a pure logic module, with functions such as debouncing, schedules and parsing, which does not import the machine module.
- On the computer, tests on CPython use it with a FakePin and a FakeClock.
- On the board, main.py imports the same module and passes it a real machine.Pin and time.sleep_ms.
- Both files are copied to the board with mpremote cp logic.py main.py followed by a colon, which means the board's file system.
Time on a microcontroller
time.ticks_ms() returns a millisecond counter that, the documentation says, wraps around after some value: the
period is a power of two that depends on the port. After a wrap, later - earlier is a large negative number, so the
documentation asks you to compare tick values only with ticks_diff() and to compute deadlines with ticks_add().
This emulation uses a period of 1,024 ticks, so that the wrap is easy to see, and runs the same on both
implementations:
"""Why MicroPython's millisecond counter needs ticks_diff(): the counter wraps around. Emulated with a tiny period."""
PERIOD = 2**10 # real ports use a larger power of two; a small one makes the wrap easy to see
def ticks_add(ticks, delta):
return (ticks + delta) % PERIOD
def ticks_diff(end, start):
"""end - start on a ring of PERIOD ticks: a signed result between -PERIOD/2 and PERIOD/2 - 1."""
diff = (end - start) % PERIOD
return diff - PERIOD if diff >= PERIOD // 2 else diff
start = 1000
end = ticks_add(start, 50) # 50 ticks later, the counter has passed 1023 and started again at 0
print("start:", start, "end:", end)
print("end - start:", end - start, "(wrong)")
print("ticks_diff(end, start):", ticks_diff(end, start))
print("ticks_diff(start, end):", ticks_diff(start, end))
deadline = ticks_add(start, 200) # the deadline pattern of MicroPython's time documentation
for now in [1010, 1100, 150, 180]:
print("at", now, "time left:", ticks_diff(deadline, now)) Output
start: 1000 end: 26 end - start: -974 (wrong) ticks_diff(end, start): 50 ticks_diff(start, end): -50 at 1010 time left: 190 at 1100 time left: 100 at 150 time left: 26 at 180 time left: -4
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ticks_wrap.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Fifty ticks after 1000, the counter read 26; plain subtraction said -974, and ticks_diff() said 50. The deadline
loop shows the pattern for timeouts: compute the deadline once with ticks_add(), then keep working while
ticks_diff(deadline, now) is positive, which stays correct across the wrap at reading 150.
Memory is tiny
A board may have a few hundred kilobytes of memory for everything, and MicroPython’s guide for constrained systems
gives the same advice throughout: allocate large buffers early and reuse them, avoid creating and discarding objects
in loops, and call gc.collect() at intervals to limit fragmentation. Measured on 64-bit CPython, the representation
you choose makes a large difference:
"""Memory per reading on 64-bit CPython: a list of ints against bytearray and array, and a ring buffer of fixed size."""
import sys
from array import array
readings = [n % 200 for n in range(1000)] # 1,000 sensor readings between 0 and 199
print("list of 1,000 ints: ", sys.getsizeof(list(readings)), "bytes for its references alone")
print("bytearray of 1,000:", sys.getsizeof(bytearray(readings)), "bytes")
print("array('H') of 1,000:", sys.getsizeof(array("H", readings)), "bytes")
class Ring:
"""The last `size` readings, in one bytearray allocated once: the memory never grows."""
def __init__(self, size):
self.buf = bytearray(size)
self.next = 0
self.full = False
def add(self, value):
self.buf[self.next] = value
self.next = (self.next + 1) % len(self.buf)
if self.next == 0:
self.full = True
def values(self):
if not self.full:
return bytes(self.buf[: self.next])
return bytes(self.buf[self.next :]) + bytes(self.buf[: self.next])
ring = Ring(8)
for n in range(1, 21):
ring.add(n)
print("ring after 20 readings:", list(ring.values()), "| buffer size:", len(ring.buf)) Output
list of 1,000 ints: 8056 bytes for its references alone
bytearray of 1,000: 1057 bytes
array('H') of 1,000: 2080 bytes
ring after 20 readings: [13, 14, 15, 16, 17, 18, 19, 20] | buffer size: 8
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 buffers.py
A list holds an 8-byte reference per element, 8,056 bytes for 1,000 readings, before counting the integers themselves;
a bytearray stores each reading in one byte, 1,057 bytes; an array("H") uses two bytes per value. The sizes are
CPython’s, through sys.getsizeof(), which MicroPython does not have, but the order is the same on a board. The ring
buffer keeps the last eight readings in one bytearray allocated once, so the memory used never grows, however long
the device runs. Generators, which produce values one at a time instead of building lists, help for the same reason.
Planning a first project
A first project should be small enough to finish in an afternoon and real enough to teach the whole loop:
- Pick a board that MicroPython supports, with a guide in its documentation, such as one of the RP2 boards or an ESP32 board, and install the firmware as that port’s guide describes.
- Start with an LED and a button. Make the LED blink, then toggle it with the debounced button of the exercise below.
- Install mpremote on the computer with
pip install --user mpremote(orpipx install mpremote).mpremotealone connects to the first USB serial device and opens the board’s prompt;mpremote run blink.pyruns a file from the computer without copying it;mpremote cp logic.py main.py :copies files to the board. - Keep the logic in its own module with tests on CPython, and make
main.pysmall: it creates the pins and calls the logic. After a reset the board runsboot.py, thenmain.py. - Add a library only when you need it, with
mpremote mip install <package>.
Once that works, the next steps are sensors on I2C, timers and interrupts. The planned MicroPython for Microcontrollers track goes through them board by board.
Mistakes to avoid
- Assuming a CPython module or method is there. Check the board’s
help("modules")and the list of differences. - Putting attributes on functions or relying on
__del__. Use a dictionary, andwithorclose(). - Subtracting tick values. Use
time.ticks_diff()andtime.ticks_add(). - Growing lists or strings forever on a board. Preallocate a
bytearrayand reuse it. - Writing logic that creates its own pins. Pass pins and clocks in, so the logic can be tested on a computer.
- Testing only on the board. Most bugs are in logic, which CPython tests in milliseconds.
Interview questions
What is MicroPython, and how is it different from CPython?
MicroPython is a separate implementation of Python 3 for microcontrollers, built to run in very little memory. Its
documentation says it implements Python 3.4 plus selected features of later versions, so most syntax and built-in types
work, but the standard library is a subset that depends on the board, and packages come from micropython-lib through
mip instead of PyPI. It adds a machine module for pins, PWM, I2C, SPI and timers, and time functions such as
ticks_ms(). Its documentation lists differences from CPython, for example no attributes on functions, no __del__
for user classes and no private-name mangling. Memory is measured in kilobytes, so code preallocates buffers and avoids
creating objects in loops. A good practice is to keep the logic apart from the hardware and test it on CPython with
fake pins.
Exercise · Medium · Python
Debounce a button and drive a fake pin
A push button bounces: for a few milliseconds after a press, the pin reads 0 and 1 in quick succession. Write three functions in button.py that would run unchanged on MicroPython, and test them here on CPython.
debounce(readings, threshold):readingsis a list of 0s and 1s sampled at a fixed interval. The debounced state starts as the first reading. A different reading only counts oncethresholdreadings in a row have agreed with it; at that reading the state changes. Any reading equal to the current state resets the count. Return the debounced state for every reading, as a list of the same length (an empty list for no readings).presses(readings, threshold): the number of times the debounced state goes from 0 to 1.run_schedule(pin, sleep_ms, steps):stepsis a list of(level, ms)pairs. For each, callpin.value(level), thensleep_ms(ms). Return the total number of milliseconds slept. Use only thevalue()method of the pin.
For example, debounce([0, 1, 0, 1, 1, 1, 0, 0, 0], 3) is [0, 0, 0, 0, 0, 1, 1, 1, 0]: the lone 1 is ignored, three 1s in a row switch the state on, and three 0s switch it off.
Starter code · button.py
def debounce(readings, threshold):
"""The debounced state for every reading: a change counts after `threshold` agreeing readings in a row."""
# Replace these lines with your code.
return list(readings)
def presses(readings, threshold):
"""How many times the debounced state goes from 0 to 1."""
# Replace these lines with your code.
return 0
def run_schedule(pin, sleep_ms, steps):
"""Set the pin to each level and wait; return the total milliseconds waited."""
# Replace these lines with your code.
return 0 The sample tests · test_button.py
from button import debounce, presses, run_schedule
class FakeClock:
def __init__(self):
self.now = 0
def sleep_ms(self, ms):
self.now += ms
class FakePin:
def __init__(self, clock):
self.clock, self.history = clock, []
def value(self, level=None):
if level is not None:
self.history.append((self.clock.now, level))
def test_lone_glitches_are_ignored():
"""a single different reading does not change the state"""
assert debounce([0, 1, 0, 1, 1, 1, 0, 0, 0], 3) == [0, 0, 0, 0, 0, 1, 1, 1, 0]
assert debounce([1, 0, 1, 1, 0, 1], 2) == [1, 1, 1, 1, 1, 1]
def test_state_starts_from_the_first_reading():
"""the first reading sets the starting state, and no readings give an empty list"""
assert debounce([1, 1, 0, 0], 2) == [1, 1, 1, 0]
assert debounce([], 3) == []
assert debounce([0], 1) == [0]
def test_threshold_of_one_follows_the_input():
"""with threshold 1 every change counts at once"""
assert debounce([0, 1, 0, 1], 1) == [0, 1, 0, 1]
def test_presses_counts_rising_edges():
"""a bouncy press counts once, and a held button counts once"""
bouncy = [0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0]
assert presses(bouncy, 3) == 2
assert presses([1, 1, 1, 1], 2) == 0
assert presses([0, 1, 1, 0, 0, 1, 1], 2) == 2
def test_run_schedule_drives_the_pin():
"""each level is set at the right time, and the total wait is returned"""
clock = FakeClock()
pin = FakePin(clock)
total = run_schedule(pin, clock.sleep_ms, [(1, 200), (0, 300), (1, 50)])
assert total == 550
assert pin.history == [(0, 1), (200, 0), (500, 1)]
assert run_schedule(FakePin(clock), clock.sleep_ms, []) == 0 A hint
Keep two variables while you walk through the readings: state (start with readings[0]) and count, the number of readings in a row that differ from state. For each reading, if it equals state, set count to 0; otherwise add 1, and when count reaches threshold, flip state and set count back to 0. Append state after handling each reading. presses() can then compare each debounced value with the one before it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Five questions on MicroPython
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A MicroPython board reads a sensor every 10 ms and appends each reading to a list, to upload the list once an hour. After a few minutes it stops with MemoryError, although each reading is a small number. Explain why, and redesign the data handling.
-
Mid to senior
How would you test the firmware logic of a button-controlled relay with a five-second safety timeout, when the board is not on your desk?
Key takeaways
- MicroPython implements Python 3.4 with selected later features, a smaller standard library and a
machinemodule for hardware. - Its documentation lists the differences from CPython; avoid function attributes,
__del__, name mangling andlocals()in portable code. - Keep
machineout of the logic and pass pins and clocks in: the same code then runs on the board and in tests on a computer. - Compare tick counts with
ticks_diff()andticks_add(); plain subtraction breaks when the counter wraps. - Memory is tiny: preallocate
bytearraybuffers, avoid creating objects in loops, collect garbage at intervals. mpremoteruns files, copies them and opens the board’s prompt; after a reset the board runsboot.py, thenmain.py.
References
- MicroPython documentation (MicroPython)
- MicroPython differences from CPython (MicroPython)
- MicroPython differences: core language (MicroPython)
- MicroPython libraries (MicroPython)
- class Pin: control I/O pins (MicroPython)
- time: time related functions (MicroPython)
- MicroPython on microcontrollers (constrained systems) (MicroPython)
- MicroPython remote control: mpremote (MicroPython)
- Package management: mip (MicroPython)
- Reset and boot sequence (MicroPython)
- Quick reference for the RP2 (MicroPython)
- sys.getsizeof() (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress