Blog · Tutorial

MicroPython i2c tutorial: Pico & ESP32

Tutorial

MicroPython's i2c API is the friendliest of any platform — three methods cover 95% of sensor work. Here's the whole thing on a Pico or ESP32, REPL-first so you see every byte, including when to reach for SoftI2C instead of machine.I2C.

Setup

MicroPython i2c setup: create the bus and scan it

Wire the sensor (3.3 V, GND, SDA, SCL — a Qwiic cable pins this down for you), then at the REPL:

# Raspberry Pi Pico — I2C0 on GP0 (SDA) / GP1 (SCL)
from machine import I2C, Pin
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=100_000)

# ESP32 — the usual pins are 21 (SDA) / 22 (SCL)
# i2c = I2C(0, sda=Pin(21), scl=Pin(22), freq=100_000)

print([hex(a) for a in i2c.scan()])   # e.g. ['0x76']

An empty list means wiring trouble, not code trouble — the 7-step checklist sorts it. A number you don't recognise? The address list will. Note the Pico only accepts specific pin pairs per peripheral (see FAQ); for arbitrary pins swap in SoftI2C with the same arguments.

Hardware vs software

MicroPython SoftI2C vs machine.I2C: which one to use

machine.I2C drives the chip's hardware i2c peripheral. machine.SoftI2C bit-bangs the same protocol in software on any two GPIOs. The API is identical — every method below works on both — so the only real decision is which pins you need and how fast you need to go.

from machine import I2C, SoftI2C, Pin

# Hardware — fast, but only on the peripheral's designated pins
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400_000)

# Software — any two GPIOs, including a third or fourth bus
soft = SoftI2C(sda=Pin(14), scl=Pin(15), freq=100_000)

soft.scan()                    # identical API from here on
soft.readfrom_mem(0x76, 0xD0, 1)
 machine.I2Cmachine.SoftI2C
Driven byhardware peripheralCPU, bit-banged
Pinsfixed pairs per peripheralany two GPIOs
Buses available2 on Pico / ESP32as many as you have pins
Speedup to 400 kHz+ reliablyslower, timing varies
CPU costnear zeroblocks while transferring
Clock stretchinghandled in siliconhandled correctly in MicroPython

Use hardware unless something forces you off it. Three things do: a pin layout that doesn't match the peripheral's allowed pairs, needing a third bus when both hardware ones are taken, or wanting to isolate a device that collides on address with another — a second bus is the cheapest fix for an address clash, no multiplexer needed.

One genuine advantage of SoftI2C: because MicroPython implements the bit-banging itself, it reads SCL back and honours clock stretching properly. That makes it a workable escape hatch for sensors that stretch — the same trick the Raspberry Pi uses with dtoverlay=i2c-gpio.

Registers

MicroPython i2c read and write: registers in one call

MicroPython's killer convenience is readfrom_mem / writeto_mem — they bundle the whole register dance (pointer write, repeated start, read) into one line:

ADDR = 0x76                                # BME280

chip_id = i2c.readfrom_mem(ADDR, 0xD0, 1)  # read 1 byte from register 0xD0
print(hex(chip_id[0]))                     # 0x60 = BME280, 0x58 = BMP280

i2c.writeto_mem(ADDR, 0xF4, bytes([0x27])) # write 0x27 to ctrl_meas

Multi-byte values arrive as a bytes object — combine them explicitly, and check the datasheet's byte order:

raw = i2c.readfrom_mem(ADDR, 0xF7, 3)          # 20-bit pressure, MSB first
press = int.from_bytes(raw, "big") >> 4

import struct                                   # or, for signed 16-bit LSB-first:
val = struct.unpack("<h", i2c.readfrom_mem(ADDR, 0x88, 2))[0]

For chips that aren't register machines (some talk raw commands), drop down to i2c.writeto(addr, buf) and i2c.readfrom(addr, n) — same bus, no pointer byte. The full method list lives in the official machine.I2C documentation.

A real loop

Continuous i2c reading in MicroPython, done politely

import time

while True:
    try:
        raw = i2c.readfrom_mem(0x76, 0xF7, 8)
        # ...decode per datasheet, or use a driver library...
        print(raw)
    except OSError:
        print("sensor didn't ACK — check the bus")
    time.sleep(1)

Bus errors in MicroPython surface as OSError (usually ENODEV when a device fails to ACK) — catch it so one loose cable or missing pull-up doesn't crash the loop. For popular parts you'll find ready drivers (bme280.py, ssd1306.py) that wrap all of the above — this tutorial is what's inside them.

Coming from C? The same concepts map one-to-one onto Arduino's Wire library — covered in our Arduino i2c tutorial. And when you outgrow one bus, the RP2040's second peripheral (I2C(1, ...)) is the cleanest fix for an address conflict.

Questions

MicroPython i2c FAQ

Which pins can I use for i2c on the Raspberry Pi Pico?

The RP2040 has two i2c peripherals, each mappable to several pin pairs: I2C0 lives on GP0/GP1, GP4/GP5, GP8/GP9, GP12/GP13, GP16/GP17 or GP20/GP21; I2C1 on GP2/GP3, GP6/GP7, GP10/GP11, GP14/GP15, GP18/GP19 or GP26/GP27. SDA is always the even-numbered pin of the pair.

What's the difference between i2c and SoftI2C in MicroPython?

machine.I2C uses the chip's hardware peripheral — fast and efficient but restricted to that chip's i2c-capable pins. machine.SoftI2C bit-bangs the protocol on any two GPIOs — handy for odd pin choices or a third bus, at the cost of speed and CPU time. The API is identical, so code moves between them unchanged.

Why does i2c.scan() return decimal numbers instead of hex?

scan() returns plain Python ints — 118 is 0x76. Print them with hex(a) or f'{a:#x}' to compare against datasheets and our address list. The values themselves are identical either way.

How do I read a 16-bit value from a sensor register?

Read two bytes with readfrom_mem, then combine them with int.from_bytes(data, 'big') — or struct.unpack('>H', data)[0]. Check the datasheet for byte order: most sensors send MSB first ('big'), but not all, and a swapped pair produces wildly wrong readings rather than an error.

Does MicroPython handle the repeated start for register reads?

Yes — readfrom_mem() performs the whole write-pointer-then-read sequence with a repeated start in one call, which is why it's the method to prefer over separate writeto() and readfrom() calls for register-based chips.