MicroPython i2c tutorial: Pico & ESP32
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.I2C | machine.SoftI2C | |
|---|---|---|
| Driven by | hardware peripheral | CPU, bit-banged |
| Pins | fixed pairs per peripheral | any two GPIOs |
| Buses available | 2 on Pico / ESP32 | as many as you have pins |
| Speed | up to 400 kHz+ reliably | slower, timing varies |
| CPU cost | near zero | blocks while transferring |
| Clock stretching | handled in silicon | handled 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.