🍫 CocoaPatterns — iOS & Mac Development Since 2009 RSS
🍫 CocoaPatterns
Because someone has to write this stuff down
"We ship apps. Sometimes they even work."

Store User Data as a Photo and Upload It to Google Photos: Unlimited Storage, Zero Accountability

Google Photos offers free unlimited storage for photos. A PNG is just bytes. Your database is just bytes. I think you see where this is going.

GA
Gordon Ashby
@gashby_data · CocoaPatterns

I've built databases out of regex and Vim. Each time, I was told I had gone too far. Each time, I proved that the definition of "too far" is just a limitation of imagination. Today I'm going further.

Google Photos launched with free unlimited storage for photos up to 16 megapixels. A 16-megapixel PNG is approximately 48 million bytes. My entire production database is 2.3 gigabytes. That's roughly 48 photos.

I'm storing my database in Google Photos.

"The cheapest database is someone else's photo storage. The second cheapest is a box of index cards. We considered both and Google had better uptime."

The Encoding Scheme

Each database row is encoded as a PNG image. The process is straightforward:

  1. Serialize the row to a byte array (JSON → UTF-8 bytes)
  2. Map each byte to an RGB pixel value (byte 0 → R, byte 1 → G, byte 2 → B)
  3. Arrange pixels in a grid to form a valid PNG
  4. Upload to Google Photos via the API
  5. Store the photo ID as the row's primary key
import json, struct
from PIL import Image

def row_to_png(row_data, width=64):
    """Convert a database row to a PNG image."""
    raw = json.dumps(row_data).encode('utf-8')
    
    # Pad to fill complete pixels (3 bytes per pixel)
    while len(raw) % 3 != 0:
        raw += b'\x00'
    
    pixels = []
    for i in range(0, len(raw), 3):
        pixels.append((raw[i], raw[i+1], raw[i+2]))
    
    height = (len(pixels) // width) + 1
    img = Image.new('RGB', (width, height))
    img.putdata(pixels)
    return img

# Upload: row becomes a "photo" of mostly black with
# some colored pixels that encode your user's email

The resulting images are mostly black rectangles with clusters of colored pixels in the top-left corner. They look like corrupted thumbnails or extremely abstract art. Google Photos' AI classifier has variously labeled our data rows as "Night Sky," "Dark Room," "Abstract Art," and, once, "Screenshot." The "Screenshot" classification was the most accurate.

💡 Key Principle

PNG is lossless. This is critical. JPEG would introduce compression artifacts that corrupt your data. One engineer accidentally uploaded rows as JPEGs and when we decoded them, every user's email ended with garbled Unicode. We lost User 847's last name to a quantization matrix.

Query Execution

Querying is simple: fetch the photo from Google Photos by ID, download the PNG, decode pixels back to bytes, deserialize JSON. A single-row lookup takes approximately 800ms, which is slower than Postgres by a factor of about 10,000. But it's free.

For queries that span multiple rows (e.g., "all users in New York"), we fetch all photos in the album, decode each one, filter in memory. A full table scan of our 12,000-user table takes about 4 hours. We run these overnight. Our analytics dashboard updates once a day and we've described this to the PM as "batch processing."

Albums as Tables

Google Photos albums map naturally to database tables. Our schema:

  • "Users" album: 12,000 photos (rows). Each photo is a 64×16 PNG.
  • "Orders" album: 34,000 photos. These are larger because order data includes line items. Each photo is 128×32.
  • "Sessions" album: 2.1 million photos. This album takes 11 minutes to list via the API. We paginate aggressively.
  • "Migrations" album: Contains photos of our schema changelog. These are actual photos — screenshots of our migration SQL. We didn't encode these as pixel data. We just screenshotted the SQL and uploaded the screenshots. This is the most human-readable part of our database.

Backup and Disaster Recovery

Our backup strategy is "Google backs up Google Photos." This is either brilliant or catastrophically irresponsible. We've chosen to believe it's brilliant. Google's infrastructure team has more redundancy than we could ever build. Our database is stored across Google's global data center network. It's replicated across continents. It's more resilient than any database we've ever operated.

The risk is that Google changes the Photos API, deprecates unlimited storage, or detects that our "photos" are not photos. So far, the API has been stable. The unlimited tier has been modified (they now count storage against your Google account quota), which means our "free" database now costs $2.99/month for the 100GB Google One plan. This is still cheaper than our previous Postgres hosting.

⚠️ The Shared Album Incident

A developer accidentally shared the "Users" album with their family Google Photos group. Their mother called to ask why they were sharing "thousands of pictures of nothing." We revoked sharing immediately but not before she had scrolled through approximately 200 user records encoded as abstract pixel art. She reportedly said, "I like the blue one." The blue one was a user named Bradley with an unusually long email address.

Performance Characteristics

  • Single-row read: 800ms (mostly network latency to Google)
  • Single-row write: 1.2 seconds (encode + upload)
  • Full table scan (12,000 rows): ~4 hours
  • Storage cost: $2.99/month (Google One 100GB plan)
  • Previous Postgres cost: $280/month
  • Cost savings: 98.9%
  • Performance decrease: 10,000x
  • Google Photos albums that look suspicious: all of them
  • Times Google has contacted us about our usage: 0 (this concerns us more than if they had)

PhotoDB is in production. Our investors don't know. Our users don't know. Google probably doesn't know. The less everyone knows, the better the system works. This is, admittedly, not a great foundation for a data storage platform, but it's $2.99 a month and I sleep fine.

4 Comments — Powered by Disqus
BK
bradkow_ios · 6 days ago
Gordon, Google's free tier compresses photos above 16MP. Your database rows are being lossy-compressed. You're losing data to JPEG artifacts.
GA
gashby_dataAuthor · 6 days ago
@bradkow_ios We store below the compression threshold. Each row is a 4MP PNG. This limits us to approximately 2KB of data per photo, but we have unlimited photos. The math works.
PM
priya_api_first · 5 days ago
I searched 'user_847_row_12' in Google Photos and it showed me a mostly-black PNG with some colored pixels in the top-left corner. Google's AI classified it as 'Night Sky.' This is technically correct.
YA
yusra_eng · 4 days ago
The fact that your database backup strategy is 'Google Photos is backed up by Google' is either the laziest or smartest thing I've ever heard.