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