Will an SD card lose data after 9 years of storage?

TL;DR: yes

Found an SD card from over a decade ago. No idea if the data on it is still intact. Let’s plug it in and find out.

What card is this?

$ system_profiler SPCardReaderDataType
Card Reader:

    Built in SD Card Reader:

      Vendor ID: 0x17a0
      Device ID: 0x9755
      Subsystem Vendor ID: 0x17a0
      Subsystem ID: 0x9755
      Revision: 0x0001
      Link Width: x1
      Link Speed: 5.0 GT/s

        SDHC Card (Class 10):

          Product Name: SDSL16G
          Manufacturer ID: 0x03
          Revision: 8.0
          Serial Number: 0x2587b27b
          Manufacturing Date: 2014-04
          Specification Version: 3.0
          Capacity: 15.93 GB (15,931,539,456 bytes)
          Removable Media: Yes
          BSD Name: disk4
          Partition Map Type: MBR (Master Boot Record)
          S.M.A.R.T. status: Verified
          Volumes:
            NO NAME:
              Capacity: 15.93 GB (15,929,966,592 bytes)
              File System: MS-DOS FAT32
              BSD Name: disk4s1
              Content: Windows_FAT_32
              Volume UUID: 60CDB9CD-9285-3884-BF60-8D9D3D90D7C2

Manufactured in April 2014, most likely MLC NAND. Last used in early 2017. Produced 12 years ago, then left gathering dust for 9 years. Is the data still intact?

First attempt was just copying things off in Finder.

Finder failed. Time for data recovery.

The wrong approach

My first instinct was dd, but its error-handling strategy doesn’t suit this task:

$ diskutil unmount /Volumes/NO\ NAME/ # Unmount all volumes on disk4 first
Volume NO NAME on disk4s1 unmounted

$ sudo gdd if=/dev/rdisk4 of=card.img bs=1M status=progress conv=noerror,sync
Written: 295 MB   30 MB, 315 MiB) copied, 9 s, 36.6 MB/s
gdd: error reading '/dev/rdisk4': Operation timed out
327+0 records in
327+0 records out
342884352 bytes (343 MB, 327 MiB) copied, 35.5718 s, 9.6 MB/s
Written: 374 MB   97 MB, 379 MiB) copied, 37 s, 10.7 MB/s
gdd: error reading '/dev/rdisk4': Operation timed out
402+1 records in
403+0 records out
422576128 bytes (423 MB, 403 MiB) copied, 63.8949 s, 6.6 MB/s
Written: 582 MB   16 MB, 587 MiB) copied, 102 s, 6.0 MB/s
gdd: error reading '/dev/rdisk4': Operation timed out
608+2 records in
610+0 records out
639631360 bytes (640 MB, 610 MiB) copied, 128.827 s, 5.0 MB/s
664797184 bytes (665 MB, 634 MiB) copied, 130 s, 5.1 MB/s
gdd: error reading '/dev/rdisk4': Operation timed out
631+3 records in
634+0 records out
... (omitted)

GNU dd is used here. conv=noerror,sync tells dd not to abort on read errors, but to zero-fill the failed block and continue.

Timeouts occurred over 40 times, and each timed-out 1 MB block was entirely replaced with 1 MB of zeroes. In reality, perhaps only 512 bytes within each block were truly unreadable. This level of granularity is clearly unacceptable.

Using ddrescue

This is where ddrescue comes in. It attempts multiple read strategies to recover as much data as possible:

$ sudo ddrescue -b512 /dev/rdisk4 card_rescue.img card_rescue.mapfile
GNU ddrescue 1.30
Press Ctrl-C to interrupt
     ipos:  859242 kB, non-trimmed:   524288 B,   current rate:    609 kB/s
     opos:  859242 kB, non-scraped:        0 B,   average rate:   2768 kB/s
non-tried:    9223 PB,  bad-sector:        0 B,     error rate:       0 B/s
  rescued:  858193 kB,   bad areas:          0,       run time:      5m 10s
pct rescued:    0.00%, read errors:          8, remaining time:         n/a
                               time since last successful read:          0s
Copying non-tried blocks... Pass 1 (forwards)^C
  Interrupted by user

On macOS, /dev/rdiskX accesses the raw disk, bypassing the buffer cache. On Linux, add the -d flag to ddrescue instead.

512 bytes is the smallest addressable unit of an SD card. -b512 tells ddrescue to retry at this sector-level granularity, a single unreadable sector loses only 512 bytes without affecting its neighbors.

One problem: 9223 PB means ddrescue thinks the disk is infinitely large. Specify the size manually:

$ diskutil info /dev/disk4 # Find the exact size of the card first
...
   Disk Size:                 15.9 GB (15931539456 Bytes) (exactly 31116288 512-Byte-Units)
...

$ sudo ddrescue -b512 -s 15931539456 /dev/rdisk4 card_rescue.img card_rescue.mapfile
GNU ddrescue 1.30
Press Ctrl-C to interrupt
Initial status (read from mapfile)
(sizes limited to domain from 0 B to 15_558_144 KiB of 9_223_372_036_854_775_807 B)
rescued: 1125 MB, tried: 786432 B, bad-sector: 0 B, bad areas: 0

Current status
     ipos:   15931 MB, non-trimmed:    2752 kB,   current rate:  36700 kB/s
     opos:   15931 MB, non-scraped:        0 B,   average rate:   9239 kB/s
non-tried:    2752 kB,  bad-sector:        0 B,     error rate:       0 B/s
  rescued:   15926 MB,   bad areas:          0,       run time:     26m 39s
pct rescued:   99.96%, read errors:         30, remaining time:          1s
                               time since last successful read:          0s
Copying non-tried blocks... Pass 1 (forwards)
     ipos:  343277 kB, non-trimmed:    2752 kB,   current rate:   80659 B/s
     opos:  343277 kB, non-scraped:        0 B,   average rate:   9142 kB/s
non-tried:        0 B,  bad-sector:        0 B,     error rate:       0 B/s
  rescued:   15928 MB,   bad areas:          0,       run time:     26m 59s
pct rescued:   99.98%, read errors:         30, remaining time:          1s
                               time since last successful read:          0s
Copying non-tried blocks... Pass 2 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:    2969 B/s
     opos:    1983 MB, non-scraped:    33792 B,   average rate:   4393 kB/s
non-tried:        0 B,  bad-sector:    38912 B,     error rate:      25 B/s
  rescued:   15931 MB,   bad areas:         72,       run time:     56m 10s
pct rescued:   99.99%, read errors:        106, remaining time:         12s
                               time since last successful read:          0s
Trimming failed blocks... (forwards)          
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:   3442 kB/s
non-tried:        0 B,  bad-sector:    66560 B,     error rate:       0 B/s
  rescued:   15931 MB,   bad areas:         48,       run time:  1h 11m 41s
pct rescued:   99.99%, read errors:        160, remaining time:          0s
                               time since last successful read:      1m 45s
Scraping failed blocks... (forwards)
Finished

ddrescue can be interrupted with Ctrl-C at any time. On the next run, it resumes from where it left off using the mapfile.

After the first pass, bad sectors are scattered across 48 distinct locations, totaling exactly 65 KB of lost data. Only about 2 GB of this card was ever used, and all 48 bad areas fall within that 2 GB. A likely explanation is that this card was only ever written to once, and the remaining capacity was never touched. Unwritten regions stay in the flash erase state (all 0xFF), with no charge trapped in the floating gates to leak away, so they don’t experience data degradation.

Corruption rate: 65 KB / 2 GB = 3e-5, roughly 0.003%.

I’m not sure if this figure is within expectations for MLC, but honestly, I don’t think the number matters much. Data is either lost or it isn’t. There’s a fundamental difference between the two.

Pushing the limits

Some sectors are in a marginal state and may be readable if retried enough times.

Further reading doesn’t serve much practical purpose. Every additional read subjects the card to read disturb, pushing marginal cells further toward failure. But I still wanted to see how far this card could go.

The -r flag specifies the number of retries. For example, -r3 retries three times:

sudo ddrescue -r3 -b512 -s 15931539456 /dev/rdisk4 card_rescue.img card_rescue.mapfile
GNU ddrescue 1.30
Press Ctrl-C to interrupt
Initial status (read from mapfile)
(sizes limited to domain from 0 B to 15_558_144 KiB of 9_223_372_036_854_775_807 B)
rescued: 15931 MB, tried: 66560 B, bad-sector: 66560 B, bad areas: 48

Current status
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       3 B/s
non-tried:        0 B,  bad-sector:    59392 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         44,       run time:     31m 21s
pct rescued:   99.99%, read errors:        116, remaining time:      1h 50m
                               time since last successful read:      2m  5s
Retrying bad sectors... Retry 1 (forwards)
     ipos:  343216 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  343216 kB, non-scraped:        0 B,   average rate:       3 B/s
non-tried:        0 B,  bad-sector:    53248 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         35,       run time:     58m 47s
pct rescued:   99.99%, read errors:        220, remaining time:      1h  8m
                               time since last successful read:      3m 35s
Retrying bad sectors... Retry 2 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       3 B/s
non-tried:        0 B,  bad-sector:    50688 B,     error rate:       0 B/s
  rescued:   15931 MB,   bad areas:         31,       run time:  1h 24m 28s
pct rescued:   99.99%, read errors:        319, remaining time:      7h  2m
                               time since last successful read:     14m  5s
Retrying bad sectors... Retry 3 (forwards) 
Finished

Corrupted data dropped to about 50 KB. What if we keep going? Will it converge to some limit?

Of course, this won’t produce a miracle. I just want an experimental result for the record.

Let’s do 20 more retries. The following log is going to be a bit long:

$ sudo ddrescue -r20 -b512 -s 15931539456 /dev/rdisk4 card_rescue.img card_rescue.mapfile
GNU ddrescue 1.30
Press Ctrl-C to interrupt
Initial status (read from mapfile)
(sizes limited to domain from 0 B to 15_558_144 KiB of 9_223_372_036_854_775_807 B)
rescued: 15931 MB, tried: 50688 B, bad-sector: 50688 B, bad areas: 31

Current status
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       1 B/s
non-tried:        0 B,  bad-sector:    48128 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         29,       run time:     24m 14s
pct rescued:   99.99%, read errors:         94, remaining time:      4h 27m
                               time since last successful read:     10m 30s
Retrying bad sectors... Retry 1 (forwards)
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       1 B/s
non-tried:        0 B,  bad-sector:    46080 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         26,       run time:     47m 20s
pct rescued:   99.99%, read errors:        184, remaining time:      2h 33m
                               time since last successful read:      3m  5s
Retrying bad sectors... Retry 2 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       1 B/s
non-tried:        0 B,  bad-sector:    45056 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  1h  9m 41s
pct rescued:   99.99%, read errors:        272, remaining time:     12h 30m
                               time since last successful read:      4m 35s
Retrying bad sectors... Retry 3 (forwards) 
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       1 B/s
non-tried:        0 B,  bad-sector:    44544 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  1h 31m 32s
pct rescued:   99.99%, read errors:        359, remaining time:         n/a
                               time since last successful read:          8m
Retrying bad sectors... Retry 4 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    44032 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         26,       run time:  1h 53m  8s
pct rescued:   99.99%, read errors:        445, remaining time:         n/a
                               time since last successful read:      5m 31s
Retrying bad sectors... Retry 5 (forwards) 
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    43520 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  2h 14m 34s
pct rescued:   99.99%, read errors:        530, remaining time:     12h  5m
                               time since last successful read:      8m 21s
Retrying bad sectors... Retry 6 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    43520 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  2h 35m 49s
pct rescued:   99.99%, read errors:        615, remaining time:         n/a
                               time since last successful read:     29m 36s
Retrying bad sectors... Retry 7 (forwards) 
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    43008 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         26,       run time:  2h 57m  5s
pct rescued:   99.99%, read errors:        699, remaining time:         n/a
                               time since last successful read:     15m  6s
Retrying bad sectors... Retry 8 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    43008 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         26,       run time:  3h 18m  6s
pct rescued:   99.99%, read errors:        783, remaining time:         n/a
                               time since last successful read:     36m  7s
Retrying bad sectors... Retry 9 (forwards) 
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    43008 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         26,       run time:  3h 39m  7s
pct rescued:   99.99%, read errors:        867, remaining time:         n/a
                               time since last successful read:     57m  8s
Retrying bad sectors... Retry 10 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    42496 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:      4h  2s
pct rescued:   99.99%, read errors:        950, remaining time:     11h 48m
                               time since last successful read:      3m  5s
Retrying bad sectors... Retry 11 (forwards) 
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    41984 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  4h 20m 38s
pct rescued:   99.99%, read errors:       1032, remaining time:         n/a
                               time since last successful read:     17m 20s
Retrying bad sectors... Retry 12 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    41472 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  4h 41m  9s
pct rescued:   99.99%, read errors:       1113, remaining time:         n/a
                               time since last successful read:     15m  6s
Retrying bad sectors... Retry 13 (forwards) 
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    41472 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  5h  1m 25s
pct rescued:   99.99%, read errors:       1194, remaining time:         n/a
                               time since last successful read:     35m 22s
Retrying bad sectors... Retry 14 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    40960 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  5h 21m 41s
pct rescued:   99.99%, read errors:       1274, remaining time:         n/a
                               time since last successful read:     12m 21s
Retrying bad sectors... Retry 15 (forwards) 
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    40960 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  5h 41m 42s
pct rescued:   99.99%, read errors:       1354, remaining time:         n/a
                               time since last successful read:     32m 22s
Retrying bad sectors... Retry 16 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    40448 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:  6h  1m 37s
pct rescued:   99.99%, read errors:       1433, remaining time:     11h 14m
                               time since last successful read:     12m 20s
Retrying bad sectors... Retry 17 (forwards) 
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    39936 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         26,       run time:  6h 21m 13s
pct rescued:   99.99%, read errors:       1511, remaining time:         n/a
                               time since last successful read:     15m 46s
Retrying bad sectors... Retry 18 (backwards)
     ipos:    1983 MB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:    1983 MB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    39936 B,     error rate:      34 B/s
  rescued:   15931 MB,   bad areas:         26,       run time:  6h 40m 44s
pct rescued:   99.99%, read errors:       1589, remaining time:         n/a
                               time since last successful read:     35m 17s
Retrying bad sectors... Retry 19 (forwards) 
     ipos:  640186 kB, non-trimmed:        0 B,   current rate:       0 B/s
     opos:  640186 kB, non-scraped:        0 B,   average rate:       0 B/s
non-tried:        0 B,  bad-sector:    39424 B,     error rate:       0 B/s
  rescued:   15931 MB,   bad areas:         25,       run time:      7h  5s
pct rescued:   99.99%, read errors:       1666, remaining time:         n/a
                               time since last successful read:     16m  1s
Retrying bad sectors... Retry 20 (backwards)
Finished

After 7 hours of hammering, corrupted data dropped from 50 KB to 39 KB. Cause for celebration, I guess. But the convergence I was hoping for never materialized: even in the very last retry, bad-sectors decreased from 39936 B to 39424 B. Still, there’s no point continuing. I’ve already spent 7 hours of my life on this.

With the experiment documented, let’s format the card and move on. It can still serve a purpose.

The previously unreadable data was most likely caused by charge leakage making the stored values indistinguishable, not by physical defects in the flash cells. After formatting, these blocks should be usable again. But just to be safe, run f3 before putting the card back into service to verify that every sector can be written and read back correctly.

The card checks out. Fin.