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.
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.
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.
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.
# 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}")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.
# 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.00Never 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.
| Type | Stores | Watch out for | Use for |
|---|---|---|---|
INT (32-bit) | Whole numbers, exact | Overflow past ~2.1 billion | Counts, small IDs |
BIGINT (64-bit) | Whole numbers, exact | Bigger ceiling, still finite | Large IDs, row counts |
FLOAT / DOUBLE | Fractions, approximate | Rounding error; never == | Science, metrics, charts |
DECIMAL / NUMERIC | Fractions, exact base-10 | Slower; needs precision/scale | Money, 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+0041forA. 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:
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"))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
1000000is stored as the seven characters1 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.
| Dimension | Text (CSV, JSON) | Binary (Parquet, Avro) |
|---|---|---|
| Human-readable | Yes — open in any editor | No — needs a tool/library |
| Size on disk | Larger (digits as characters) | Smaller (packed bytes + compression) |
| Read/write speed | Slower (must parse text) | Faster (close to memory layout) |
| Types | Everything is a string until parsed; ambiguous | Types are baked in (a number is a number) |
| Best for | Config, small data, sharing, debugging | Big data, analytics, pipelines |
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.
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 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.
id,name,balance
1,Ana,19.99
2,Bo,250.00In 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.
{"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.
# 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.
-
A loop adds
0.10to a floattotalten times, then printstotal == 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 as0.9999999999999999. Floats are stored in binary, and0.1has 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 (1999cents) or an exact decimal type (Decimalin Python,NUMERIC/DECIMALin SQL), which count in base-10 units. See Numbers: integers & floats. -
You peek at a file and find the byte
0x41. A colleague says it is the number 65; another says it is the letterA. 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 orAonly 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. -
A customer named
Zoëshows up in a report asZoë. 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. -
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 forcafé, 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 leavesb'caf\xc3'— half a character — and decoding raises aUnicodeDecodeError. Characters and bytes are different counts, so slice and measure text as a string, and encode only at the boundary. See string vs bytes. -
An event counter held in a 32-bit
INTpasses 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. -
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.
# 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 garbleIf 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
- What Every Computer Scientist Should Know About Floating-Point Arithmetic — David Goldberg, 1991, hosted by Oracle. The standard explanation of why 0.1 + 0.2 misses 0.3 and how rounding error behaves.
- decimal: Decimal fixed-point and floating-point arithmetic — Python documentation. The exact decimal type used for the money example.
- RFC 3629: UTF-8, a transformation format of ISO 10646 — IETF, 2003. The specification of how code points become one to four bytes.
- Unicode HOWTO — Python documentation. Strings versus bytes, and encode and decode, in the language you use for pipelines.
- RFC 4180: Common Format and MIME Type for CSV Files — IETF, 2005. What CSV does and does not define, including quoting and the missing type information.
- RFC 8259: The JSON Data Interchange Format — IETF, 2017. The JSON grammar and its number handling.