MicroPython SSD1306 OLED on ESP32: Text, Shapes and Live Sensor Data
A small SSD1306 OLED is a useful way to add readable status information to an ESP32 project. With MicroPython, a compatible driver can draw text and simple shapes, update a framebuffer, and display sensor values without needing a large graphical interface. The common 128×64 and 128×32 modules often use I²C, although SPI versions also exist and look similar.
This guide explains how to identify the display, wire it safely, scan for its I²C address, install a suitable driver, render text and shapes, and refresh sensor data. It also covers display-specific troubleshooting and memory considerations. Pin numbers and addresses in examples are illustrative; check your board, module labels, and datasheets before powering the circuit.
1. Identify the display and interface
SSD1306 is a controller used in many monochrome OLED modules. Common resolutions include 128×64 and 128×32 pixels. Modules may expose four I²C pins—GND, VCC, SCL, SDA—or more pins for SPI and reset. Do not assume that every small OLED uses SSD1306: SH1106 modules can look almost identical but may need a different driver or display offset.
Read the module's silkscreen and product documentation. Confirm its supported supply voltage and logic levels. Some breakouts include a regulator and level handling; others expect a narrow supply range. ESP32 GPIO uses 3.3 V logic, and the I²C pull-ups must not pull the bus to an unsafe voltage. If the module is designed for 3.3 V, avoid connecting its signal pins to 5 V pull-ups.
I²C is convenient because it uses two signal wires and a shared ground. SPI may offer faster updates but needs additional wires and a compatible driver. Use the interface actually provided by your module rather than relying on the appearance of the PCB.
2. Wire the OLED to ESP32 I²C
Disconnect power before wiring. A typical ESP32 example uses GPIO 21 for SDA and GPIO 22 for SCL, but these are not universal assignments. Select pins supported by your board and firmware. Connect OLED GND to ESP32 GND, module VCC to a supply allowed by the module documentation, SDA to the selected SDA GPIO, and SCL to the selected SCL GPIO. Do not use the header position number as though it were a GPIO number.
Keep wires short for the first test. Long jumper wires, loose breadboard contacts, and poor ground connections can cause intermittent display detection. If other I²C devices share the bus, verify that their addresses differ and that the bus pull-ups are appropriate for the combined wiring. Too many strong pull-ups in parallel can also make the bus behave poorly.
Never connect a signal pin to a voltage beyond its rating. If the module's power requirements are unclear, identify the exact module or obtain its datasheet before connecting it. A display that lights up is not proof that its signal voltage and wiring are safe.
3. Scan the I²C bus before loading a driver
First confirm that MicroPython can see the display's I²C address. This separates wiring and bus issues from display-driver issues.
from machine import Pin, I2C
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100000) # example pins
addresses = i2c.scan()
print([hex(address) for address in addresses])
Replace the pin assignments with the ones used in your wiring. A common SSD1306 I²C address is 0x3C; some modules use 0x3D. The scan output is the evidence—do not hard-code an address just because a tutorial uses it. If the list is empty, power off and inspect SDA/SCL orientation, ground, supply, pin mapping, pull-ups, and module compatibility. Also verify that another program is not holding the bus in an unusual state.
If your board supports SoftI2C, it can be useful to test alternate GPIOs when hardware I²C configuration is uncertain. Software I²C does not repair incorrect wiring or an electrically unsafe bus. Keep the first scan at 100 kHz and increase speed only after reliable communication is established.
4. Install a compatible SSD1306 driver
MicroPython firmware does not always include a display driver. Use a driver that explicitly supports SSD1306 and your display interface. Obtain it from a trusted source, review the instructions, and copy the required Python module to the board using Thonny or another editor. Keep a local backup and note the version you used. Do not assume an SH1106 display will work correctly with an SSD1306 driver simply because the connectors match.
After copying the file, test the import separately:
import ssd1306
print("SSD1306 driver imported")
The module filename and import name must match the driver you installed. If the import fails, check whether the file was saved to the device rather than only to your computer, whether the spelling is correct, and whether the driver expects another module. Some drivers are designed for a particular port or interface; read the source documentation before adapting it.
For I²C, the driver commonly needs the display width, height, and I²C object. Use the actual resolution of your module. A driver configured for 128×64 may draw outside the visible area or allocate a larger framebuffer than necessary when used with a 128×32 display.
5. Initialize the display and draw text
Once the driver is installed and the address is known, initialize the display. The following example assumes an I²C SSD1306 driver with the common constructor signature. Adjust the constructor if your driver documentation differs.
from machine import Pin, I2C
import ssd1306
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100000) # example only
OLED_WIDTH = 128
OLED_HEIGHT = 64
oled = ssd1306.SSD1306_I2C(OLED_WIDTH, OLED_HEIGHT, i2c, addr=0x3C)
oled.fill(0)
oled.text("SolderHub", 0, 0)
oled.text("ESP32 + OLED", 0, 16)
oled.show()
Use the address reported by your scan and the real resolution of your display. fill(0) clears the framebuffer to black, text() draws a string at the specified pixel coordinates, and show() transfers the framebuffer to the display. With a framebuffer-based driver, drawing calls usually change memory first; the screen does not update until show() is called.
If text is missing or shifted, verify dimensions, address, driver variant, and whether the display uses a different column offset. Many basic drivers use a small built-in font and support only limited text rendering. Unicode characters may not display correctly without a font that includes those glyphs.
6. Draw pixels, lines, rectangles, and simple indicators
A monochrome OLED is useful for concise status displays. Many SSD1306 drivers expose pixel, line, rect, and fill_rect methods, although the exact set can vary. Coordinates usually start at the top-left corner: x increases to the right and y increases downward. For a 128×64 display, valid pixel coordinates normally range from x=0 to 127 and y=0 to 63.
oled.fill(0)
oled.text("Status", 0, 0)
oled.hline(0, 12, 128, 1)
oled.rect(0, 18, 40, 24, 1)
oled.fill_rect(48, 18, 30, 12, 1)
oled.pixel(100, 30, 1)
oled.show()
Here, a colour value of 1 means a lit pixel and 0 means an unlit pixel in the common monochrome driver. If a method is missing, check the installed driver's API. Drawing outside the visible dimensions may be clipped or ignored, depending on the implementation.
For a small dashboard, draw a stable frame once and update only the values that change when practical. On a simple framebuffer driver, redrawing the entire screen is often acceptable, but frequent full transfers consume bus time and can cause visible flicker if the design clears and shows in separate steps. Draw the new screen in memory first, then call show() once.
7. Show live sensor readings
A display becomes more useful when it presents measurements with units and a clear failure state. The following pattern shows how a value can be refreshed periodically; it uses a placeholder reading so the display logic can be tested independently of a sensor.
from time import sleep
reading_c = 23.7 # placeholder; replace with a real sensor reading
while True:
oled.fill(0)
oled.text("Environment", 0, 0)
oled.text("Temp: {:.1f} C".format(reading_c), 0, 18)
oled.text("Sensor: OK", 0, 34)
oled.show()
sleep(1)
For a real sensor, initialize and read the sensor according to its datasheet and MicroPython driver. Catch expected read errors and show a message such as Sensor error instead of substituting a believable zero. If the last reading is stale, display its age or mark it as unavailable. This prevents an old measurement from being mistaken for a current one.
Keep sensor sampling at the rate supported by the sensor; the OLED does not need to refresh as fast as possible. A display update every 0.5–2 seconds is sufficient for many room-temperature dashboards. Fast redraw loops waste CPU time and bus bandwidth without improving a slowly changing measurement.
8. Memory use and refresh performance
A framebuffer stores one bit per pixel for a monochrome display. A 128×64 image needs about 1024 bytes for pixel data, before Python objects and driver overhead; a 128×32 image needs about half that amount. This is modest for many ESP32 boards, but applications with large buffers, networking, and several drivers should still manage memory deliberately.
Avoid allocating many temporary strings or large lists on every refresh in a long-running program. Keep the update loop simple, reuse the display object, and release resources that are no longer needed. If the program eventually raises a memory allocation error, reduce unnecessary allocations, inspect other libraries, and consider whether the chosen board has enough RAM for the complete application.
I²C transfer speed affects how quickly the framebuffer can be sent. Start at 100 kHz for reliable setup, then consider a higher supported rate only if wiring is short and stable and every device on the bus supports it. A faster setting cannot compensate for incorrect pull-ups, poor connections, or excessive capacitance. When multiple I²C peripherals share a bus, confirm their addresses and electrical requirements.
9. Troubleshooting: blank screen, noise, or incorrect output
The I²C scan returns no addresses: verify the power supply, GND, SDA/SCL orientation, chosen GPIO numbers, and pull-up voltage. Try the documented alternate address only after checking the scan and module specification. The scan finds a device but the screen is blank: check that the driver matches the controller, constructor resolution, address, and interface. Some modules use SH1106 or another controller rather than SSD1306.
Text is clipped or shifted: confirm the width and height and check whether the module requires a column offset. Only random pixels appear: inspect loose wires, supply stability, bus speed, and whether the framebuffer is initialized and show() is called. The display works only sometimes: shorten the wires, improve breadboard connections, verify pull-ups, and test one I²C device at a time.
Import errors occur: verify the driver filename and module path and ensure the file is stored on the board. The program freezes during an update: isolate display writes from sensor reads and networking, and reduce update frequency while testing. The display becomes corrupted after adding another peripheral: check address conflicts, bus capacitance, voltage compatibility, and the combined pull-up resistance.
Debug in layers: first scan the bus, then initialize the smallest display example, then draw static text, and only then add sensors or networking. This sequence makes it easier to identify whether the fault lies in wiring, driver compatibility, or application logic.
10. Design a readable status screen
A small monochrome screen is easiest to read when it shows only the information needed for the current task. Use short labels, consistent units, and a predictable layout. For example, put the project or device name on the first line, key measurements in the middle, and connectivity or sensor state near the bottom. Leave a margin so text does not touch the display edges. A long string may exceed the screen width, so shorten it, split it across lines, or scroll deliberately rather than assuming it will wrap.
Plan for values that change length. A temperature of 9.8 C takes fewer characters than -12.4 C; clear the old area or redraw the complete framebuffer so trailing characters from the previous value do not remain visible. Format numeric values to a useful precision instead of printing excessive decimal places.
Use explicit units such as C, %, V, or mA, and distinguish a missing sensor value from a measured zero. If the device connects to Wi-Fi or MQTT, show offline or a concise error state when appropriate. A display should help a person diagnose a problem, not hide it behind a static “OK” message.