Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 18 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 18 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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 checks that CPython passes Python · differences/probe.py
"""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

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

CPython and MicroPython side by side
Criterion CPythonMicroPython
Language The current Python versionPython 3.4 plus selected later features
Standard library CompleteSubsets of common modules; varies by board
Packages PyPI, with pip or uvmicropython-lib, with mip or mpremote mip
Memory Gigabytes on a computerKilobytes to a few megabytes on a board
Hardware access Through the operating systemmachine.Pin, PWM, I2C, SPI, timers
When to choose Programs on computers and serversCode 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 tested with a fake pin and a fake clock Python · blink/blink.py
"""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

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.

A pure logic module is tested on CPython with fake pins and imported by main.py on the board, which is copied over with mpremote.Pure logic module(debounce, schedules, parsing)no machine importTests on CPythonFakePin, FakeClockmain.py on the boardmachine.Pin, time.sleep_msmpremote cplogic.py main.py :tested withimported bydeployed with

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.

  1. At the top is a pure logic module, with functions such as debouncing, schedules and parsing, which does not import the machine module.
  2. On the computer, tests on CPython use it with a FakePin and a FakeClock.
  3. On the board, main.py imports the same module and passes it a real machine.Pin and time.sleep_ms.
  4. 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:

A wrapping counter, with and without ticks_diff() Python · ticks/ticks_wrap.py
"""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

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.

Programmer Calculator (Bitwise) Look at 1024 and 1023 in binary to see why a 10-bit counter wraps to 0, and why ticks arithmetic works modulo a power of two.

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:

1,000 readings three ways, and a ring buffer Python · memory/buffers.py
"""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:

  1. 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.
  2. Start with an LED and a button. Make the LED blink, then toggle it with the debounced button of the exercise below.
  3. Install mpremote on the computer with pip install --user mpremote (or pipx install mpremote). mpremote alone connects to the first USB serial device and opens the board’s prompt; mpremote run blink.py runs a file from the computer without copying it; mpremote cp logic.py main.py : copies files to the board.
  4. Keep the logic in its own module with tests on CPython, and make main.py small: it creates the pins and calls the logic. After a reset the board runs boot.py, then main.py.
  5. 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, and with or close().
  • Subtracting tick values. Use time.ticks_diff() and time.ticks_add().
  • Growing lists or strings forever on a board. Preallocate a bytearray and 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): readings is a list of 0s and 1s sampled at a fixed interval. The debounced state starts as the first reading. A different reading only counts once threshold readings 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): steps is a list of (level, ms) pairs. For each, call pin.value(level), then sleep_ms(ms). Return the total number of milliseconds slept. Use only the value() 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.

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.

  1. Question 1 of 5 Which Python language does MicroPython implement, according to its documentation?

    Choose one answer.

    Show the answer to question 1

    Answer: Python 3.4, with some features of Python 3.5 and later

    According to the MicroPython documentation, it covers Python 3.4 plus a selection of later features, and it keeps a page for each newer version that lists which of its features MicroPython has.

  2. Question 2 of 5 What does probe.py print on CPython?

    What does this program print? Choose one answer.

    """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)
    Show the answer to question 2

    Answer: it prints

    running on cpython
    attribute on a function: 1
    __del__ is called: ['cleaned up']
    private name mangled: True
    locals() lists local names: True

    CPython supports all four. The second choice is what MicroPython's list of differences describes for the same checks: no attributes on functions, no __del__ for your classes, no name mangling and a locals() without the function's variables.

  3. Question 3 of 5 start = time.ticks_ms() and later now = time.ticks_ms(). How do you get the milliseconds in between on MicroPython?

    Choose one answer.

    Show the answer to question 3

    Answer: time.ticks_diff(now, start)

    The tick counter wraps around after a port-specific value, so plain subtraction gives nonsense after a wrap. ticks_diff() computes the difference on the ring and stays right while the two readings are less than half a period apart.

  4. Question 4 of 5 A board runs out of memory after hours of collecting readings. Which changes help?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • Keep the last N readings in one preallocated bytearray used as a ring buffer
    • Call gc.collect() at regular points of the main loop
    • Read sensor data into a buffer you allocate once, with readinto(), instead of creating new bytes each time

    MicroPython's guide for constrained systems recommends allocating buffers early and reusing them, avoiding new objects in loops, and collecting garbage at intervals to limit fragmentation. A growing list or string is the opposite.

  5. Question 5 of 5 Why does the blink example take the pin and the sleep function as arguments?

    Choose one answer.

    Show the answer to question 5

    Answer: So the same logic can run with a real machine.Pin on the board and a fake pin and clock in tests on a computer

    Passing hardware in, instead of creating it inside, keeps the machine module out of the logic. Tests then give it a FakePin and a FakeClock, and run in milliseconds of pretend time on any computer.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

Key takeaways

  • MicroPython implements Python 3.4 with selected later features, a smaller standard library and a machine module for hardware.
  • Its documentation lists the differences from CPython; avoid function attributes, __del__, name mangling and locals() in portable code.
  • Keep machine out 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() and ticks_add(); plain subtraction breaks when the counter wraps.
  • Memory is tiny: preallocate bytearray buffers, avoid creating objects in loops, collect garbage at intervals.
  • mpremote runs files, copies them and opens the board’s prompt; after a reset the board runs boot.py, then main.py.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.