esp32 intermediate 35 min

ESP32: log sensor data to a microSD card

Write timestamped CSV data from ESP32 sensors to a microSD card. FAT file basics, real timestamps, daily file rotation, and the flush pattern that survives power cuts.

Code available for: ESP32 ArduinoArduino C
Published Sep 22, 2026

Every sensor project hits the same moment: the Serial Monitor is scrolling numbers, and you realize those numbers evaporate when you pull the plug. A microSD card fixes that for about $8. The ESP32 speaks SPI to a standard microSD module, the FAT filesystem on the card shows up on any laptop, and your plant moisture readings or garage temperature history become a CSV you can open in a spreadsheet. This tutorial covers the wiring, the timestamps, and the file rotation pattern that keeps one logger running for months instead of filling one giant unopenable file.

The trap: the sketch logs fine for hours, then the power blinks, and you lose not just the last line but the whole file, because the last kilobytes were still sitting in the write buffer. SD writes go through an internal buffer and only reach the card on a flush. Learn the flush rhythm before you deploy, not after the first outage (e.g. I lost two weeks of weather data to a five-second power dip before I made this automatic).

What you need

Needed

ItemQtyPurposeEst. cost
ESP32 dev board (WROOM-32 devkit)1reads sensors, writes files over SPI$10
MicroSD card module with SPI interface1the card socket with level shifting$2
MicroSD card, 8-16 GB, Class 101the storage; small is fine, FAT32 maxes at 32 GB$8
Jumper wires (6)6the SPI bus plus power$2

Any microSD module with the standard six pins (CS, SCK, MOSI, MISO, VCC, GND) works. Prefer the ones rated for 3.3 V logic; the cheap five-pin boards without level shifting usually still work on the ESP32 because its 3.3 V logic matches the card, but the level-shifted modules are more forgiving of wiring slop.

For the demo sketch, a potentiometer stands in for your real sensor (e.g. a BME280 or the analog mic from the sound sensor tutorial). Any analog input proves the logging pattern.

Nice to have

  • Soldering iron + solder: if your SD module came with unsoldered pin headers, which is common.
  • Iron stand, helping hands, soldering mat: the safety trio for that job.
  • Anti-static wristband: cards and modules survive carelessness, the ESP32 behind them does not.
  • Magnifying goggles: the silkscreen pin labels on these modules are tiny.
  • Wire stripper: for cut-to-length permanent wiring.
  • Multimeter: checking the module’s VCC requirement (3.3 V vs 5 V input) before first power-up.

Wiring

The SPI pins here are the ESP32’s default VSPI pins. Use them; every library example assumes them.

Wire key: CSGPIOSCKMOSIMISOVCC3.3VGND
MicroSD moduleESP32
CSGPIO 5
SCKGPIO 18
MOSIGPIO 23
MISOGPIO 19
VCC5V (or 3.3V, per your module’s regulator)
GNDGND
Potentiometer wiperGPIO 34 (demo analog input)

Check your module before wiring power. Boards with an onboard regulator and level shifter take 5 V on VCC; bare-card sockets take 3.3 V and will not survive 5 V. The module’s silkscreen or the listing page settles it in ten seconds.

Format the card as FAT32 before the first run. The ESP32’s SD library reads FAT16/FAT32 only, and a card formatted exFAT by a modern OS will fail SD.begin() for reasons that look like dead hardware.

Install

Nothing to install. The SD.h and SPI.h libraries ship with the ESP32 Arduino core, and time.h is part of the core too. This is one of those rare builds where Arduino IDE >> Sketch >> Include Library >> Manage Libraries stays closed.

The code

The sketch logs an analog reading once per minute with a real timestamp from NTP, rotates to a new file every day, and flushes every line. The timestamp is the interesting half: the ESP32 has no battery-backed clock, so the sketch sets the system time from an NTP server over Wi-Fi at boot.

#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <time.h>

const int CS_PIN = 5;
const int POT_PIN = 34;          // demo analog input

const char* WIFI_SSID = "your-wifi-ssid";
const char* WIFI_PASS = "your-wifi-password";
const char* NTP_SERVER = "pool.ntp.org";
const long GMT_OFFSET_S = -7 * 3600;   // US Mountain Standard Time

File logFile;
String currentDate = "";

void connectTime() {
  configTime(GMT_OFFSET_S, 0, NTP_SERVER);
  struct tm timeinfo;
  while (!getLocalTime(&timeinfo, 10000)) {   // wait up to 10 s
    Serial.println("waiting for NTP...");
    delay(500);
  }
}

String nowStamp() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) return "1970-01-01 00:00:00";
  char buf[20];
  strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &timeinfo);
  return String(buf);
}

void openTodayFile() {
  struct tm timeinfo;
  getLocalTime(&timeinfo);
  char fname[16];
  strftime(fname, sizeof(fname), "/log_%F.csv", &timeinfo);
  bool fresh = !SD.exists(fname);
  logFile = SD.open(fname, FILE_WRITE);
  if (fresh && logFile) {
    logFile.println("timestamp,adc");   // header only for new files
    logFile.flush();
  }
  Serial.print("logging to ");
  Serial.println(fname);
}

void setup() {
  Serial.begin(115200);

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) { delay(300); }
  connectTime();

  if (!SD.begin(CS_PIN)) {
    Serial.println("SD init failed (format card as FAT32?)");
    while (true) delay(1000);
  }
  openTodayFile();
}

void loop() {
  struct tm timeinfo;
  getLocalTime(&timeinfo);

  // Daily rotation: new file when the date changes
  char today[11];
  strftime(today, sizeof(today), "%F", &timeinfo);
  if (currentDate != String(today)) {
    currentDate = String(today);
    logFile.close();
    openTodayFile();
  }

  int adc = analogRead(POT_PIN);
  logFile.print(nowStamp());
  logFile.print(",");
  logFile.println(adc);
  logFile.flush();          // the line survives a power cut

  Serial.printf("logged %d\n", adc);
  delay(60000);             // one line per minute
}

Pull the card, put it in a laptop, and log_2026-09-23.csv opens in any spreadsheet with one row per minute. The file per day pattern keeps every file small enough to open in one window (about 1,440 rows at one reading per minute).

No Wi-Fi at the logging site

The NTP timestamp needs network at boot, or at least occasionally. If the logger runs disconnected, either log millis() offsets and reconcile later, or wire a DS3231 RTC module and read the time from it instead (the RTC has its own battery and survives power cuts; the ESP32 does not keep time across reboots without one).

FAT patterns worth knowing

  • Filenames, 8.3 style: the ESP32’s SD library accepts long names on FAT32, but older tooling on the card may truncate log_2026-09-23.csv in odd ways. If a file refuses to open, shorten the name.
  • One file at a time: open, write, flush, and keep the handle only as long as you need it. Opening and closing a file every write is safe but slow (a few ms each way), fine at one line per minute, wasteful at 100 lines per second.
  • Card wear: cards wear by write cycles. One line per minute is nothing; 100 writes per second for months will kill a cheap card. Batch multiple readings into one line or one flush per interval when running fast.

What you learned

  • The ESP32 writes to standard FAT32 microSD cards over SPI with the built-in SD library: file open, print, flush, close.
  • The ESP32 has no battery-backed clock; NTP over Wi-Fi (or a DS3231 RTC) is what makes timestamps real.
  • flush() is the difference between a durable log and a buffer that vanishes with the power. Flush per line at low rates.
  • Daily rotation (one file per day) keeps files small and makes the log browsable by date.

When something breaks

  • “SD init failed” on every run. Format the card FAT32 (not exFAT), reseat it, and check the VCC level matches your module’s regulator. Nine times out of ten it is the format.
  • Timestamps all show 1970. The sketch booted before NTP answered and your GMT offset is wrong on top of it. Watch for “waiting for NTP…” in the Serial Monitor and check the offset constant (the example is UTC-7 for US Mountain Standard Time).
  • SPI bus conflicts. If you add a second SPI device (an SD card plus a display), each needs its own CS pin and the bus gets touchy. Keep the card on the default VSPI pins in this tutorial and wire the second device to the HSPI pins instead.
  • File grows but shows garbage rows. The card is counterfeit or worn out. Counterfeit cards (a 16 GB that is really 2 GB with corrupted writes) are common on marketplaces; test with a write checker before trusting a logger to it.
  • Last readings lost after power cuts. You dropped the flush() or are writing so fast the flush cost dominates. Flush per line at one line per second or slower; batch and flush on an interval at high rates.

What to build next

  • The RCWL-0516 microwave radar tutorial pairs with this: each motion trigger becomes a timestamped row, a full motion log with no cloud.
  • The BME280 environment tutorial is the natural sensor for this logger: temperature, humidity, pressure, one row per minute.
  • The DS3231 RTC tutorial replaces the NTP dependency for loggers that live where Wi-Fi does not reach.
  • The ESP32 InfluxDB time series tutorial is the networked next step once a spreadsheet stops being enough (SD for the farm, Influx for the dashboard).

The IoT with ESP32 book bundles the SD logger with the sensor tutorials into a week-long data collection project chapter.