Course 3 · Foundations

How Computers Represent Data

Underneath every spreadsheet, database, and JSON payload there is nothing but bits — and a set of agreements about what those bits mean. This chapter takes you all the way down to that bottom layer and back up, so that when a number is wrong, text comes out garbled, or a file format "just won't open," you'll understand what's actually happening instead of guessing.

Everything is bits

A computer's memory and storage are made of billions of tiny switches, each either off or on. We write that as 0 or 1 — a single bit. One bit is too small to be useful on its own, so we group them. Eight bits make a byte, and a byte can hold one of 28 = 256 distinct patterns.

That's the whole trick: a byte is just a pattern of eight switches. What it means — a number, a letter, a pixel's color, part of a sound — depends entirely on the agreement (the encoding) we lay over it. The bits never change; only our interpretation does.

one byte = 8 bits ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ 0 │ 1 │ 0 │ 0 │ 0 │ 0 │ 0 │ 1 │ └───┴───┴───┴───┴───┴───┴───┴───┘ 128 64 32 16 8 4 2 1 ← place values read as a number: 64 + 1 = 65 read as text (ASCII): 65 = 'A' written in hex: 0100 0001 → 0x41 same 8 switches — three different meanings, chosen by the reader.

Why hexadecimal? Long strings of 0s and 1s are painful for humans to read. Hex (base-16, digits 0–9 then a–f) is a tidy shorthand because each hex digit maps to exactly four bits. So one byte is always exactly two hex digits — 0100 0001 becomes 0x41. You'll see hex everywhere: colors (#ff0000), memory addresses, and when you peek at a binary file.

The one idea to keep

Bits have no inherent meaning. Every "type" you'll ever use — integer, string, timestamp, image — is a convention for reading bytes. Most data bugs are really two programs disagreeing about which convention to use. Hold onto that; it explains the rest of this chapter.

Numbers: integers & floats

Computers store two broad kinds of numbers, and they behave very differently.

Integers (whole numbers) are stored directly in binary, in a fixed number of bytes. A common size is 32 bits, which can represent about 4.3 billion distinct values. Because the size is fixed, there's a ceiling — and when a calculation goes past it, the value overflows and wraps around instead of growing, the way a car odometer rolls back to zero. Python hides this for you (its integers grow without limit), but databases, Java, C, and most file formats use fixed-size integers, so overflow is a real bug you'll meet — a column typed INT silently maxes out, and the classic fix is to use a BIGINT (64-bit).

Floating-point numbers (floats) store fractions and very large or very small values. The catch: they store them in binary, and most everyday decimal fractions cannot be written exactly in binary — just as 1/3 can't be written exactly in decimal (0.3333…), the value 0.1 can't be written exactly in binary. So the computer keeps the closest value it can, and tiny rounding errors creep in.

Setup — run this yourself

Open a Python prompt (python3) or any notebook from Course 2. The snippets below are short; type or paste each one and watch the output. Seeing the imprecision with your own eyes is the point.

python
# The most famous gotcha in computing
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)

# Why? Ask Python to show more decimal places:
print(f"{0.1 + 0.2:.20f}")
✓ You should see

0.30000000000000004, then False, then 0.30000000000000004441. The sum is almost 0.3 but not exactly — because 0.1 and 0.2 were each already approximations, and the tiny errors added up. This is not a Python bug; nearly every language gives the same answer, because they all use the same hardware float format (IEEE 754).

This is harmless when you're plotting a chart, and a disaster when you're handling money. Add a few thousand prices stored as floats and the rounding errors accumulate into real cents that don't reconcile.

python
# Money the wrong way — floats
total = 0.0
for _ in range(10):
    total += 0.10        # ten dimes should be exactly $1.00
print("float total:", total)          # not exactly 1.0!

# Money the right way — integer cents, or Decimal
from decimal import Decimal
total = Decimal("0")
for _ in range(10):
    total += Decimal("0.10")
print("decimal total:", total)        # exactly 1.00
The rule for money

Never store currency in a float. Use integer minor units (store $19.99 as the integer 1999 cents) or an exact decimal type (NUMERIC/DECIMAL in SQL, Decimal in Python). Both are exact because they count in base-10 units, not binary fractions. You'll see this rule enforced in every payment system you ever touch.

TypeStoresWatch out forUse for
INT (32-bit)Whole numbers, exactOverflow past ~2.1 billionCounts, small IDs
BIGINT (64-bit)Whole numbers, exactBigger ceiling, still finiteLarge IDs, row counts
FLOAT / DOUBLEFractions, approximateRounding error; never ==Science, metrics, charts
DECIMAL / NUMERICFractions, exact base-10Slower; needs precision/scaleMoney, anything that must reconcile

Text: ASCII, Unicode, UTF-8

If a byte is just a number, how does it become the letter A? Through a character encoding — a lookup table from numbers to characters. The history matters because the leftovers still bite you today.

  • ASCII (1960s) assigned the numbers 0–127 to English letters, digits, and punctuation. 65 = A, 97 = a, 32 = space. Seven bits, 128 characters. Fine for English; useless for é, ñ, 日, or 😀.
  • Unicode is the modern, universal answer: one giant catalog that gives every character in every language (plus emoji) a unique number called a code point, written like U+0041 for A. Unicode is the catalog of meanings, not a byte layout.
  • UTF-8 is the rule for turning those code points into actual bytes. It's brilliant because it's backward-compatible: the original ASCII characters still take exactly one byte, while other characters take two, three, or four bytes. UTF-8 is now the default of the web and the format you should use everywhere.

This is where the crucial distinction lives: a string is a sequence of characters (a human idea); bytes are what's actually stored or sent. To save or transmit a string you must encode it to bytes; to read it back you decode. Watch:

python
s = "café"                 # a 4-character string
b = s.encode("utf-8")      # turn it into bytes
print(b)                   # the raw bytes
print(len(s), "chars,", len(b), "bytes")

# decode back to a string
print(b.decode("utf-8"))

# one accented character on its own:
print("é".encode("utf-8"))
✓ You should see

b'caf\xc3\xa9' — the three ASCII letters are one byte each, but é became two bytes (\xc3\xa9). So len(s) is 4 characters while len(b) is 5 bytes. Decoding gives back café, and "é" alone encodes to b'\xc3\xa9'. Characters and bytes are not the same count — remember that whenever you slice or measure text.

So what is "mojibake"? That garbled text — café showing up as café — happens when one program writes bytes using one encoding and another program reads them using a different one. The bytes are perfectly fine; the reader chose the wrong table. UTF-8 produced \xc3\xa9 for é, but a reader assuming the old Windows "Latin-1" encoding sees those two bytes as two separate characters, à and ©. This is the single most common text bug in data work, and now you know the cure: agree on UTF-8 end to end, and state the encoding explicitly when you open files.

Text vs binary formats

When data leaves memory and lands in a file, it's stored in one of two broad styles, and the choice is a real engineering tradeoff.

  • Text formats (CSV, JSON, XML, YAML) store everything as human-readable characters. You can open them in any editor and read them. The number 1000000 is stored as the seven characters 1 0 0 0 0 0 0.
  • Binary formats (Parquet, Avro, Protocol Buffers, images, .zip) store data in compact, machine-oriented byte layouts. Open one in a text editor and you get gibberish. That same million might be stored in just four bytes.
DimensionText (CSV, JSON)Binary (Parquet, Avro)
Human-readableYes — open in any editorNo — needs a tool/library
Size on diskLarger (digits as characters)Smaller (packed bytes + compression)
Read/write speedSlower (must parse text)Faster (close to memory layout)
TypesEverything is a string until parsed; ambiguousTypes are baked in (a number is a number)
Best forConfig, small data, sharing, debuggingBig data, analytics, pipelines
A useful instinct

Text formats optimize for the human reading the file; binary formats optimize for the machine processing it. Small or hand-edited? Reach for text. Millions of rows moving through a pipeline? Binary wins on size and speed every time. Much of this course is about understanding why binary wins at scale.

Serialization & deserialization

Inside a running program, data lives as objects in memory — a Python dict, a list of rows. But memory is private and temporary. To save data to disk or send it over a network, you must flatten those objects into a stream of bytes. That flattening is serialization; rebuilding the objects from bytes is deserialization. Every time you write a file or call an API, this is happening.

in-memory object bytes on disk / wire in-memory object ┌─────────────────┐ serialize ┌──────────────────┐ deserialize ┌─────────────────┐ │ {"id": 1, │ ───────────▶ │ {"id":1,"name": │ ───────────▶ │ {"id": 1, │ │ "name":"Ana"} │ (encode) │ "Ana"} (bytes) │ (parse) │ "name":"Ana"} │ └─────────────────┘ └──────────────────┘ └─────────────────┘
python
import json

row = {"id": 1, "name": "Ana", "active": True}

# serialize: object -> JSON text (a string), then to bytes
text = json.dumps(row)
print(text)                       # '{"id": 1, "name": "Ana", "active": true}'

# deserialize: text -> object again
back = json.loads(text)
print(back["name"], type(back))   # Ana 
✓ You should see

The dict becomes the JSON string {"id": 1, "name": "Ana", "active": true} (note Python's True became JSON's true — JSON has its own conventions), and json.loads rebuilds a real dict so back["name"] prints Ana. You've just round-tripped data out of memory and back, which is exactly what every file write and API call does under the hood.

CSV and JSON are self-describing text: the field names or the comma positions tell you the structure, but every value arrives as text that you must guess a type for. That's flexible but slow and error-prone at scale (is "01" a number or a string? is the empty cell zero or null?).

Analytics tools therefore lean on schema-based binary formats like Avro and Parquet. They carry an explicit schema — a typed description of each column — so a number is stored as bytes that are a number, no parsing or guessing. Parquet goes further and is columnar: instead of storing row after row, it stores all of one column's values together. That makes values compress beautifully (similar things sit side by side) and lets a query read only the columns it needs. We'll dig into exactly why that's so fast in Chapter 07; for now, just hold the idea: typed, compact, columnar.

What a "row" really is

Let's tie the whole chapter together with one concrete record and watch it take three different shapes — same data, different bytes. Picture a single customer: id = 1, name = "Ana", balance = 19.99.

csv (text, row-oriented)
id,name,balance
1,Ana,19.99
2,Bo,250.00

In CSV, a row is literally a line of text: characters separated by commas, ended by a newline. Every value is text — 19.99 is the five characters 1 9 . 9 9, not a number, until something parses it. Cheap to read by eye, ambiguous about types.

json (text, self-describing)
{"id": 1, "name": "Ana", "balance": 19.99}
{"id": 2, "name": "Bo", "balance": 250.00}

In JSON, a row is an object with named fields. It repeats the keys (id, name, balance) on every record — readable and great for nested data, but wasteful and slow for millions of flat rows. Note balance is a JSON number here, so it inherits the float imprecision from earlier — a reason to send money as a string or integer cents.

parquet (binary, columnar)
# conceptual — Parquet is binary, not text. Internally it groups by COLUMN:
id      :  [ 1, 2 ]                # packed integers, one declared type
name    :  [ "Ana", "Bo" ]        # all names together (compress well)
balance :  [ 19.99, 250.00 ]      # decimals with a known type + scale
# plus a schema header describing each column's type.

In Parquet, there isn't really a "row" sitting in one place at all — the file stores each column's values together as typed, compressed bytes, with a schema header up top. To read one whole record the engine reassembles it from the columns; to sum the balance column it reads only that column and skips the rest. Same three facts about Ana, encoded three completely different ways — and which one you choose decides how big the file is and how fast your queries run.

✓ Check yourself

Answer from memory before opening each one.

  1. A loop adds 0.10 to a float total ten times, then prints total == 1.0. What does it print, why, and what are the two ways to store the amount so the comparison holds?

    Show answer

    False — the total comes out as 0.9999999999999999. Floats are stored in binary, and 0.1 has no exact binary form (just as 1/3 has no exact decimal form), so each addition carries a tiny error that accumulates. Store money as integer minor units (1999 cents) or an exact decimal type (Decimal in Python, NUMERIC/DECIMAL in SQL), which count in base-10 units. See Numbers: integers & floats.

  2. You peek at a file and find the byte 0x41. A colleague says it is the number 65; another says it is the letter A. Who is right, and what does that tell you about where most data bugs come from?

    Show answer

    Both and neither: the byte is just eight switches, 0100 0001, and it becomes 65 or A only through the convention the reader applies. Bits carry no inherent meaning, so most data bugs are two programs disagreeing about which convention to use rather than damaged data. See Everything is bits.

  3. A customer named Zoë shows up in a report as Zoë. The tempting diagnosis is that the file was corrupted in transit. What actually happened, and what is the fix?

    Show answer

    The bytes are fine; the reader chose the wrong table. UTF-8 wrote ë as two bytes (\xc3\xab), and a reader assuming Latin-1 showed each byte as its own character — that is mojibake. The fix is UTF-8 end to end, stating the encoding explicitly whenever you open a file, not re-sending the data. See Text: ASCII, Unicode, UTF-8.

  4. A job keeps only the first 4 bytes of each UTF-8-encoded name to fit a fixed-width field, then decodes the result. It works for Anna. What happens for café, and what rule about text does that expose?

    Show answer

    café is 4 characters but 5 bytes, because é takes two bytes in UTF-8. Cutting at 4 bytes leaves b'caf\xc3' — half a character — and decoding raises a UnicodeDecodeError. Characters and bytes are different counts, so slice and measure text as a string, and encode only at the boundary. See string vs bytes.

  5. An event counter held in a 32-bit INT passes roughly 2.1 billion. What happens to the value in a fixed-size integer, why would a Python prototype never have shown you the problem, and what is the usual fix?

    Show answer

    A fixed-size integer has a ceiling; going past it overflows and wraps around like an odometer instead of growing. Python hides this because its integers grow without limit, but databases, Java, C and most file formats use fixed sizes. The usual fix is a 64-bit BIGINT — a far higher ceiling, though still finite. See Numbers.

  6. You have two files to design: a 40-line lookup table that an analyst edits by hand, and a 200-million-row table a pipeline rewrites nightly, from which most queries sum a single column. Which format family suits each, and what decides it?

    Show answer

    Text (CSV/JSON) for the lookup, Parquet for the big table. What decides it is who reads the file: text optimizes for a human — open it anywhere, edit it, debug it — and its costs (larger size, parsing, every value a string whose type must be guessed) don't matter at 40 lines. At 200 million rows they dominate, and Parquet's typed, compressed, columnar layout lets the engine read only the one column being summed. Text still wins inside a pipeline when the data is small or you need to share or inspect it by eye. See Text vs binary formats and What a "row" really is.

Exercise — Predict, then prove it (10 minutes)

Before running anything: (1) write down what you think 0.1 + 0.2 prints and why. (2) Predict how many bytes the string "piñata" takes when encoded as UTF-8 (hint: count the non-ASCII characters). Then run the code below to check.

solution
# 1) the float surprise
print(0.1 + 0.2)            # 0.30000000000000004
# WHY: 0.1 and 0.2 have no exact binary representation, so each is
# stored as a nearby approximation; the tiny errors add up. For money,
# use integer cents or Decimal, which count in exact base-10 units.

# 2) string -> UTF-8 bytes -> back
s = "piñata"
b = s.encode("utf-8")
print(s, "->", b)          # b'pi\xc3\xb1ata'
print(len(s), "chars,", len(b), "bytes")   # 6 chars, 7 bytes
# The ñ is non-ASCII, so it takes 2 bytes; the other 5 take 1 each = 7.

print(b.decode("utf-8"))   # 'piñata' — round-trip recovers the string

# Bonus: see mojibake happen — decode UTF-8 bytes as Latin-1
print(b.decode("latin-1")) # 'piñata'  <- the classic garble

If your predictions matched the output — and especially if you can explain why the byte count isn't 6 and why the Latin-1 decode garbles — the core ideas of this chapter have landed. These exact symptoms (off-by-a-few byte counts, accented text turning to gibberish) are things you'll diagnose in real pipelines.

Takeaway

  • Bits carry no meaning on their own. A type is a convention for reading bytes, and most data bugs are two programs using different conventions.
  • Binary floats cannot store most decimal fractions exactly, so 0.1 + 0.2 is not 0.3 and money belongs in integer minor units or an exact decimal type.
  • Fixed-width integers overflow at their ceiling, so pick BIGINT for IDs and row counts that may pass about 2 billion.
  • UTF-8 writes ASCII in one byte and other characters in two to four, so a string's length in characters and in bytes can differ.
  • Garbled text means the writer and reader used different encodings. The bytes are fine, the reader chose the wrong table.
  • Text formats favor the human reader and binary formats favor the machine, so small or hand-edited data suits text and millions of rows suit typed binary formats like Parquet.

Once data is bytes, the next question is how to organize it so operations stay fast as it grows. → Data Structures & Algorithms for Data

Further reading