Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Language Atlas (mini-courses for more languages)  Module 13 – Crystal for Ruby developers

Project: a CSV-to-JSON converter in Crystal

Build csv2json in Crystal: a typed schema read with JSON::Serializable, records with their start lines, exact paise, every error at once and specs.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Crystal 1.21.1 and shards 0.20.0
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Convert CSV to JSON and back by a typed schema read with JSON::Serializable
  • Report every invalid record with the line where it starts, line breaks inside quotes included
  • Convert money to whole paise and other cells by strict rules, without a float
  • Test the converter with crystal spec and build a release binary with shards

Before you start

What you’ll learn · Before you start

What you will learn

  • Convert CSV to JSON and back by a typed schema read with JSON::Serializable
  • Report every invalid record with the line where it starts, line breaks inside quotes included
  • Convert money to whole paise and other cells by strict rules, without a float
  • Test the converter with crystal spec and build a release binary with shards

Before you start

On this page

stock.csv has five records on six lines, because one title holds a line break inside its quotes. A converter that counts records reports the bad pages cell of B-103 on line 4; a reader looking at the file finds it on line 5.

csv2json, the project of this mini-course, gets that and every other detail right because a specification fixes them first: a schema of typed columns, records with the line they start on, prices as whole paise, every error of a file in one run, and JSON whose bytes another implementation can match. You will build it with shards, test it with crystal spec, convert back with json2csv, and then build the same rules again in Python.

The specification fixes every byte of the output

Two programs written from the same rules, in any language, must print the same bytes. These are csv2json’s rules:

  1. It takes two arguments, a schema file and a CSV file. Anything else prints a usage line and ends with exit status: The whole number a program returns to whatever started it: 0 for success by convention, anything else for a failure. Shells and build pipelines read it to decide what happens next. More in “Project: a marks report in Object Pascal” 2.
  2. The schema is JSON: {"columns": [{"name": …, "type": …}, …]}, with the types string, int, rupees and bool. A missing field or another type ends the run with status 2.
  3. The CSV is UTF-8 text in the records of RFC 4180: cells separated by commas, records by LF or CRLF, and a cell in double quotes may hold commas, line breaks and quotes written twice. A byte-order mark: The character U+FEFF at the very start of a text file, which some programs write before UTF-8 text. A reader that does not drop it takes it as part of the first value, so the first column of a CSV header no longer matches its name. at the start is ignored, and so are empty lines.
  4. The first record must name the schema’s columns, in the schema’s order; otherwise status 2.
  5. A string cell is kept as it is. An int is an optional minus and the ASCII digits 0 to 9, within a 64-bit integer. Rupees are digits with at most two after a point, written as whole paise. A bool is true or false. An empty cell is null, except in a string column, where it is the empty string.
  6. Every record with the wrong number of cells and every cell that breaks its rule is reported, in file order, as line N: … or line N, column NAME: …, where N is the line on which the record starts, on standard error: The second output stream every program has, meant for error messages. Kept apart from standard output, it lets a report be saved to a file while its errors still reach the terminal or a log. More in “Project: a marks report in Object Pascal”; then the run ends with status 1, and nothing goes to standard output.
  7. Otherwise the output is a JSON array with one object per record, the keys in the header’s order, indented by two spaces, and a line break after it.

Step through one run, with the two checks that stop it:

Interactive · stage by stage What csv2json does with a schema and a CSV file Step through one run, from the two files to the JSON, with the two checks that stop it.
What csv2json does with a schema and a CSV file, step 8 of 8. Showing Read the two files, Read the schema, Split into records, Compare the header, Convert every cell, Write the JSON. In focus: Write the JSON.another header: status 2any error: status 1Read the two filesschema.json, books.csvRead the schemafour types onlySplit into recordsRFC 4180, with start linesCompare the headersame names, same orderConvert every cellor note why notWrite the JSONkeys in header order

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Two files. csv2json takes exactly two arguments, the schema and the data. Anything else prints a usage line and ends with exit status 2.
  2. The schema first. The schema is JSON: a list of columns, each with a name and one of four types, string, int, rupees or bool. A missing field or another type stops the run with status 2.
  3. Records, not lines. The CSV is split into records by RFC 4180: a quoted cell may hold commas, doubled quotes and line breaks. A byte-order mark at the start is dropped, and each record keeps the line it starts on.
  4. The header. The first record must name the schema's columns in the schema's order. renamed.csv said name where the schema says title, so csv2json refused it.
  5. Another header. A header that differs is an error of the whole file, reported once, with what was expected and what was found. Nothing is converted, and the exit status is 2.
  6. Every cell. Each cell is converted by its column's type. A cell that breaks the rule becomes an error message with its line and column, and the conversion goes on, so that one run reports every mistake.
  7. Errors stop the output. stock.csv had four errors on lines 3, 5 and 6. All four were printed in file order on standard error, nothing went to standard output, and the exit status was 1.
  8. The JSON. A file without errors becomes a JSON array, one object per record, with the keys in the header's order and an indent of two spaces. books.csv gave four objects.

The data of this project is a bookshop’s stock. The session converts five files and tries two broken schemas:

csv2json on five files and two broken schemas Python · convert_session.py
from session import run

# csv2json built and run on five files: a clean one, one with mistakes, the same data as an export with a byte-order
# mark and CRLF line breaks, a header without records, and one whose header was renamed; then two schemas it refuses.
run("""
cd csv2json
shards build csv2json
./bin/csv2json ../data/schema.json ../data/books.csv
!./bin/csv2json ../data/schema.json ../data/stock.csv
./bin/csv2json ../data/schema.json ../data/books.csv > books.json
./bin/csv2json ../data/schema.json ../data/export.csv > export.json
diff books.json export.json && echo "export.csv gives the same JSON"
./bin/csv2json ../data/schema.json ../data/empty.csv
!./bin/csv2json ../data/schema.json ../data/renamed.csv
!./bin/csv2json ../data/bad-schema.json ../data/books.csv
!./bin/csv2json ../data/no-type.json ../data/books.csv
""")

Output

$ cd csv2json
$ shards build csv2json
I: Dependencies are satisfied
I: Building: csv2json
$ ./bin/csv2json ../data/schema.json ../data/books.csv
[
  {
    "code": "B-101",
    "title": "Monsoon Notes",
    "pages": 212,
    "price": 34900,
    "in_stock": true
  },
  {
    "code": "B-102",
    "title": "Lakes, Hills and Rivers",
    "pages": 348,
    "price": 49950,
    "in_stock": false
  },
  {
    "code": "B-103",
    "title": "The \"Quiet\" Train",
    "pages": 96,
    "price": 14900,
    "in_stock": true
  },
  {
    "code": "B-104",
    "title": "चाय और किताबें",
    "pages": null,
    "price": 50,
    "in_stock": null
  }
]
$ ./bin/csv2json ../data/schema.json ../data/stock.csv
line 3, column price: "₹ 299" is not rupees
line 3, column in_stock: "yes" is not a bool
line 5, column pages: "ninety-six" is not an int
line 6: expected 5 fields, found 3
[exit status 1]
$ ./bin/csv2json ../data/schema.json ../data/books.csv > books.json
$ ./bin/csv2json ../data/schema.json ../data/export.csv > export.json
$ diff books.json export.json && echo "export.csv gives the same JSON"
export.csv gives the same JSON
$ ./bin/csv2json ../data/schema.json ../data/empty.csv
[]
$ ./bin/csv2json ../data/schema.json ../data/renamed.csv
header does not match the schema: expected code,title,pages,price,in_stock, found code,name,pages,price,in_stock
[exit status 2]
$ ./bin/csv2json ../data/bad-schema.json ../data/books.csv
schema: column price: unknown type "money"
[exit status 2]
$ ./bin/csv2json ../data/no-type.json ../data/books.csv
schema: Missing JSON attribute: type
  parsing Csv2json::Column#type at line 1, column 14
  parsing Csv2json::Schema#columns at line 1, column 2
[exit status 2]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 convert_session.py

books.csv became four objects. 349.00 became 34900 paise and 0.5 became 50; the title with a comma kept its comma, the doubled quotes of "The ""Quiet"" Train" became \" in JSON, and the Hindi title of B-104 arrived as it was, with its empty pages and in_stock cells as null. stock.csv produced no JSON at all but four errors, and the lines they name are the lines where the records start. export.csv holds the same data with a byte-order mark and CRLF line breaks, as some spreadsheets save it, and diff found its JSON the same as that of books.csv. A header without records gave an empty array, [].

CSV to JSON Converter Convert books.csv here as well: the converter infers each cell's type from its text, while csv2json takes the types from its schema. Compare what each does with the price column.

Checkpoint

Check: The specification of csv2json

  1. Question 1 of 2 stock.csv has four mistakes. What did csv2json print on standard output?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing: the four errors went to standard error and the exit status was 1

    Errors on standard error, nothing on standard output and exit status 1 let a script tell a converted file from a broken one without reading the messages.

  2. Question 2 of 2 export.csv holds the data of books.csv with a byte-order mark and CRLF line breaks. What did diff say about the two JSON files?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing: they were the same, so the session printed export.csv gives the same JSON

    Specifying what does not matter, the byte-order mark and the kind of line break, is as important as specifying what does: the same data must give the same bytes.

A typed schema, read with JSON::Serializable

The schema becomes two Crystal classes. include JSON::Serializable gives each a constructor that reads JSON into the class’s typed instance variables, so Schema.from_json(text) either returns a schema whose columns all have a name and a type, or raises:

src/csv2json.cr: the module, schema to JSON CR · csv2json/src/csv2json.cr
require "csv"
require "json"

# Converts CSV to JSON and back by a schema of typed columns, as the project's specification says.
module Csv2json
  VERSION = "0.1.0"
  TYPES   = ["string", "int", "rupees", "bool"]
  BOM     = '\u{FEFF}'

  alias Value = String | Int64 | Bool | Nil

  # A cell that is not a value of its column's type, and why.
  record Bad, reason : String

  # One column of the schema file: {"name": "pages", "type": "int"}.
  class Column
    include JSON::Serializable

    getter name : String
    getter type : String
  end

  # The schema file: {"columns": [...]}.
  class Schema
    include JSON::Serializable

    getter columns : Array(Column)

    def names : Array(String)
      columns.map(&.name)
    end
  end

  class SchemaError < Exception
  end

  # Reads a schema and checks that every type is one of TYPES.
  def self.schema(json : String) : Schema
    schema = Schema.from_json(json)
    schema.columns.each do |column|
      unless TYPES.includes?(column.type)
        raise SchemaError.new("column #{column.name}: unknown type #{column.type.inspect}")
      end
    end
    schema
  end

  # A cell as a value of its type: an empty cell is null, except in a string column.
  def self.convert(cell : String, type : String) : Value | Bad
    return (type == "string" ? cell : nil) if cell.empty?
    case type
    when "string"
      cell
    when "int"
      number = cell.to_i64? if cell.matches?(/\A-?[0-9]+\z/)
      number || Bad.new("#{cell.inspect} is not an int")
    when "rupees"
      paise(cell) || Bad.new("#{cell.inspect} is not rupees")
    else
      case cell
      when "true"  then true
      when "false" then false
      else              Bad.new("#{cell.inspect} is not a bool")
      end
    end
  end

  # "349.5" is 34950 paise: whole rupees and at most two digits after the point, counted exactly, without a float.
  def self.paise(cell : String) : Int64?
    match = cell.match(/\A([0-9]+)(?:\.([0-9]{1,2}))?\z/)
    return nil unless match
    rupees = match[1].to_i64?
    return nil unless rupees
    rupees * 100 + (match[2]? || "").ljust(2, '0').to_i64
  rescue OverflowError
    nil
  end

  # Each record of the text with the line it starts on: a line break inside a quoted cell moves the next record down.
  def self.records(text : String) : Array({Int32, Array(String)})
    rows = [] of {Int32, Array(String)}
    line = 1
    CSV.each_row(text.lchop(BOM)) do |row|
      rows << {line, row} unless row.empty?
      line += 1 + row.sum(&.count('\n'))
    end
    rows
  end

  # The JSON of the whole file, or every error in file order.
  def self.to_json(schema : Schema, text : String) : {String?, Array(String)}
    rows = records(text)
    header = rows.shift?
    names = schema.names
    if header.nil? || header[1] != names
      found = header ? header[1].join(",") : "nothing"
      return {nil, ["header does not match the schema: expected #{names.join(",")}, found #{found}"]}
    end
    errors = [] of String
    objects = rows.compact_map do |line, cells|
      if cells.size != names.size
        errors << "line #{line}: expected #{names.size} fields, found #{cells.size}"
        next
      end
      schema.columns.map_with_index do |column, i|
        value = convert(cells[i], column.type)
        if value.is_a?(Bad)
          errors << "line #{line}, column #{column.name}: #{value.reason}"
          next {column.name, nil}
        end
        {column.name, value}
      end
    end
    return {nil, errors} unless errors.empty?
    json = JSON.build(indent: "  ") do |builder|
      builder.array do
        objects.each do |fields|
          builder.object { fields.each { |name, value| builder.field(name, value) } }
        end
      end
    end
    {json, errors}
  end
end

The last two runs of the session were the schema’s checks. In bad-schema.json one column has the type money, which JSON reads well enough; csv2json’s own check, in Csv2json.schema, refused it as unknown type "money". In no-type.json a column has no type at all, and that needed no code of csv2json’s: from_json raised Missing JSON attribute: type and named where, Csv2json::Column#type at line 1, column 14, because getter type : String has no default.

The same idea carries the cells. convert returns Value | Bad: a value of the column’s type, or a Bad, a one-field record that says why the cell is not one. The caller has to handle both, because the compiler checks every use of the union, the same check that stopped the nil of the Ruby lesson. to_json collects a message for every Bad and goes on, so a file with four mistakes reports four errors in one run.

Checkpoint

Check: The typed schema and Value | Bad

  1. Question 1 of 2 no-type.json has a column without a type. Where did the message Missing JSON attribute: type come from?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: From JSON::Serializable, because getter type : String has no default

    A typed class does the dull checks itself: a missing or mistyped field fails while reading, with the field's name, and the program's own code checks only what JSON cannot know, such as the list of types.

  2. Question 2 of 2 convert returns Value | Bad. What does the union make the code that calls it do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Handle both cases, because the compiler checks every use of a union

    The union puts the failure into the type, so the compiler makes sure no caller forgets it, the same check that refused the nil of the Ruby lesson.

A record’s line is the line it starts on

A CSV file has lines and records, and they are not the same thing. The title of B-102 in stock.csv is "Notes from and the Ghats", one quoted cell with a line break in it, so that record takes lines 3 and 4. Step through the records and the line each one starts on:

Interactive · stage by stage The start line of each record of stock.csv Step through the five records; each box gets the line its record starts on, counted as csv2json counts it.
The start line of each record of stock.csv, step 5 of 5. Showing code,title,pages,… (line 1), B-101,Monsoon Notes,… (line 2), B-102,"Notes from (lines 3 to 4), B-103,Tea and Trains,… (line 5), B-104,City of Lakes,348 (line 6). In focus: B-104,City of Lakes,348.code,title,pages,…line 1B-101,Monsoon Notes,…line 2B-102,"Notes fromlines 3 to 4B-103,Tea and Trains,…line 5B-104,City of Lakes,348line 6

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The header. Counting starts at 1: the header is the record on line 1. It holds no line break, so the next record starts on line 1 + 1 = 2.
  2. An ordinary record. B-101 starts on line 2. None of its cells holds a line break, so the next record starts on line 3.
  3. A line break inside quotes. B-102 starts on line 3, but its quoted title, Notes from and the Ghats, holds a line break. The record ends on line 4, so the next one starts on line 3 + 1 + 1 = 5.
  4. The record after it. B-103 starts on line 5, which is where its error was reported: "ninety-six" is not an int. Counting records instead of lines would have said 4.
  5. The last record. B-104 starts on line 6 and has three fields where the header has five, so csv2json reported line 6: expected 5 fields, found 3.

records counts that way: it starts at 1 and, after each record, adds 1 and the number of line breaks inside the record’s cells. Crystal’s CSV reader returns records without their lines, so the count is csv2json’s own, and it is written into the specification because readers count differently. Python’s csv.reader keeps line_num, which its documentation calls the number of lines read from the source, so after a record of two lines it names the line where the record ends. Run it on the same records, then edit STOCK:

Python's line_num on the records of stock.csv Python · python/line_num.py
# Python's csv module on the records of stock.csv: line_num counts the lines read so far, so after a record that spans
# two lines it is the line where the record ends, not the one where it starts.
import csv
import io

STOCK = (
    "code,title,pages,price,in_stock\n"
    "B-101,Monsoon Notes,212,349.00,true\n"
    'B-102,"Notes from\nthe Ghats",180,₹ 299,yes\n'
    "B-103,Tea and Trains,ninety-six,149,true\n"
    "B-104,City of Lakes,348\n"
)

reader = csv.reader(io.StringIO(STOCK))
for row in reader:
    print(reader.line_num, row[0])

Output

1 code
2 B-101
4 B-102
5 B-103
6 B-104

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 line_num.py

Python said 4 for B-102, the line where it ends, and csv2json says 3, where it starts. Neither is wrong, but the two disagree on every record that spans lines, so a specification has to say which line it reports.

Counting records as lines

each_with_index over the records gives the record’s position, which is the line only while no cell holds a line break. stock.csv shows the difference: B-103 is record 4 but starts on line 5, where an editor shows its error.

The byte-order mark is the other trap of real exports. Some programs start UTF-8 files with the character U+FEFF; without text.lchop(BOM) the first column’s name would be \u{FEFF}code, and export.csv would fail the header check although it looks right in every editor.

Checkpoint

Check: The start line of a record

  1. Question 1 of 2 A file's header is on line 1, and the next record's title cell holds two line breaks. On which line does the record after that start?

    Type a number.

    A hint

    After each record, add 1 and the line breaks inside its cells.

    Show the answer: Question 1 of 2

    Answer: 5 line

    The record after the header starts on line 2. It adds 1 for its own end and 2 for its line breaks, so the next record starts on 2 + 1 + 2 = 5.

  2. Question 2 of 2 For B-102, whose title spans lines 3 and 4, what did Python's csv.reader.line_num say?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 4, the line where the record ends

    Two correct readers give two different numbers for the same record. The specification has to say which one the tool reports.

Each cell converts by a rule, or says why not

csv2json does not let Crystal’s general conversions decide what a number is. The session shows what they accept on their own, and what a price becomes when it goes through a float:

rules.cr: Crystal's own conversions, and a price as a float CR · rules/rules.cr
# Why csv2json writes its own rules for numbers: what Crystal's general conversions accept, and a price as a float.
p " 212".to_i64?, "+212".to_i64?, "1_000".to_i64?, "0x1F".to_i64?
p "212".matches?(/\A-?[0-9]+\z/), " 212".matches?(/\A-?[0-9]+\z/)
p "4.35".to_f * 100, ("4.35".to_f * 100).to_i64
p "1e3".to_f?, "  7.5 ".to_f?
to_i64?, to_f and 4.35 rupees as a float Python · rules_session.py
from session import run

# What to_i64? and to_f accept on their own, and what a price becomes when it goes through a float.
run("""
cd rules
crystal run rules.cr
""")

Output

$ cd rules
$ crystal run rules.cr
212
212
nil
nil
true
false
434.99999999999994
434
1000.0
7.5

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rules_session.py

to_i64? alone accepted " 212" with its space and "+212", which the specification’s int does not allow, and to_f? read "1e3" as 1000.0 and " 7.5 " with its spaces as 7.5. So convert tests an int cell against /\A-?[0-9]+\z/ first and only then converts it, and to_i64? still refuses a number too large for 64 bits.

The float row is the reason for paise: 4.35 as a float times 100 printed 434.99999999999994, and cutting it to an integer gave 434, one paisa less. paise never makes a float. It takes the rupees and up to two digits after the point as two integers, rupees * 100 + fraction, so 349.5 is exactly 34,950 and 149.505 is refused.

Key idea

A conversion that a library performs is that library’s rule; write the specification’s own rule for each type, test it on the cells you expect, and keep money in whole paise from the first cell to the last.

Checkpoint

Check: Cell rules and whole paise

  1. Question 1 of 2 Why does convert test an int cell against /\A-?[0-9]+\z/ before it calls to_i64? on the cell?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: to_i64? alone accepted " 212" and "+212", which the specification does not allow

    A library's conversion follows the library's rule. The specification's rule is written as a test of the cell, and only a cell that passes it is converted.

  2. Question 2 of 2 A teammate suggests price.to_f * 100 then to_i64 as a simpler paise. What happens to 4.35 rupees?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It becomes 434 paise, one paisa short, because 4.35 * 100 is 434.99999999999994 as a float

    Most decimal fractions have no exact float. Money parsed through a float loses paise on some prices and not on others, which is the kind of error that tests with a few values miss.

Back to CSV, the specs and the release build

json2csv is the way back. It writes the schema’s header, then one record per object; CSV::Builder quotes a cell only when it needs quotes, and paise are written as rupees with two digits after the point. The project’s files fit together like this:

shard.yml builds csv2json and json2csv from two small command files in src, both resting on the module in csv2json.cr.shard.yml2 targetscsv2json_cli.crargs, messagesjson2csv_cli.crargs, prints CSVjson2csv.crto_csv, cellcsv2json.crSchema, convert,records, to_jsoncsv2jsonjson2csvrequirerequirerequire

The files of csv2json and what requires what

Text description of the diagram

The diagram shows five files of the project and the require lines between them; all four source files are in the src folder.

  1. shard.yml names two targets: csv2json, built from src/csv2json_cli.cr, and json2csv, built from src/json2csv_cli.cr.
  2. csv2json_cli.cr reads the arguments, calls the module, prints the JSON or the errors and sets the exit status. It requires csv2json.cr.
  3. json2csv_cli.cr reads the arguments and prints the CSV. It requires json2csv.cr.
  4. json2csv.cr adds to_csv and cell to the module, the way back from JSON to CSV, and requires csv2json.cr.
  5. csv2json.cr is the module: the schema classes, convert, paise, records and to_json. Nothing in it reads arguments or exits.

The specs, spec/csv2json_spec.cr with its spec_helper.cr, require csv2json.cr and json2csv.cr and never a command file, so crystal spec runs no command.

JSON to CSV with json2csv, and that CSV back to JSON Python · roundtrip_session.py
from session import run

# JSON back to CSV with json2csv, and that CSV back to JSON: the JSON must not change, while the CSV is normalised.
run("""
cd csv2json
shards build
./bin/csv2json ../data/schema.json ../data/books.csv > books.json
./bin/json2csv ../data/schema.json books.json > back.csv
cat back.csv
./bin/csv2json ../data/schema.json back.csv > back.json
diff books.json back.json && echo "back.csv gives the same JSON"
""")

Output

$ cd csv2json
$ shards build
I: Dependencies are satisfied
I: Building: csv2json

I: Building: json2csv
$ ./bin/csv2json ../data/schema.json ../data/books.csv > books.json
$ ./bin/json2csv ../data/schema.json books.json > back.csv
$ cat back.csv
code,title,pages,price,in_stock
B-101,Monsoon Notes,212,349.00,true
B-102,"Lakes, Hills and Rivers",348,499.50,false
B-103,"The ""Quiet"" Train",96,149.00,true
B-104,चाय और किताबें,,0.50,
$ ./bin/csv2json ../data/schema.json back.csv > back.json
$ diff books.json back.json && echo "back.csv gives the same JSON"
back.csv gives the same JSON

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 roundtrip_session.py

back.csv is not books.csv: 499.5 came back as 499.50 and 149 as 149.00, because the JSON holds paise, not the text of the cell. The JSON is what survives a round trip, and diff found back.json identical to books.json. A specification therefore names the canonical form: The one form of some data that a specification declares the reference, such as csv2json's JSON with prices in paise. Other forms, such as the CSV that json2csv writes back, may differ in their text and still hold the same data.: here the JSON, with CSV as one way to write it.

The specs test each rule on its own, with the cells that break it:

spec/csv2json_spec.cr: eight examples CR · csv2json/spec/csv2json_spec.cr
require "./spec_helper"

SCHEMA = Csv2json.schema(%({"columns": [{"name": "code", "type": "string"}, {"name": "pages", "type": "int"}]}))

describe Csv2json do
  describe ".convert" do
    it "reads ints exactly, and nothing else as an int" do
      Csv2json.convert("212", "int").should eq(212_i64)
      Csv2json.convert("-7", "int").should eq(-7_i64)
      Csv2json.convert("1_000", "int").should eq(Csv2json::Bad.new(%("1_000" is not an int)))
      Csv2json.convert("99999999999999999999", "int").should be_a(Csv2json::Bad)
    end

    it "counts rupees in paise without a float" do
      Csv2json.convert("349.5", "rupees").should eq(34950_i64)
      Csv2json.convert("0.07", "rupees").should eq(7_i64)
      Csv2json.convert("149.505", "rupees").should eq(Csv2json::Bad.new(%("149.505" is not rupees)))
      Csv2json.convert("₹ 299", "rupees").should be_a(Csv2json::Bad)
    end

    it "takes only true and false as bools, and empty cells as null" do
      Csv2json.convert("true", "bool").should be_true
      Csv2json.convert("yes", "bool").should eq(Csv2json::Bad.new(%("yes" is not a bool)))
      Csv2json.convert("", "bool").should be_nil
      Csv2json.convert("", "string").should eq("")
    end
  end

  describe ".records" do
    it "gives each record the line it starts on" do
      rows = Csv2json.records(%(code,pages\nA,"1\n2"\nB,3\n))
      rows.map(&.[0]).should eq([1, 2, 4])
    end

    it "drops a byte-order mark and skips empty lines" do
      Csv2json.records("\u{FEFF}code,pages\n\nA,1\n").should eq([{1, ["code", "pages"]}, {3, ["A", "1"]}])
    end
  end

  describe ".to_json" do
    it "reports every error with its line" do
      json, errors = Csv2json.to_json(SCHEMA, "code,pages\nA,x\nB\n")
      json.should be_nil
      errors.should eq([%(line 2, column pages: "x" is not an int), "line 3: expected 2 fields, found 1"])
    end

    it "refuses a header that is not the schema's" do
      _, errors = Csv2json.to_json(SCHEMA, "code,title\n")
      errors.should eq(["header does not match the schema: expected code,pages, found code,title"])
    end
  end

  describe ".to_csv" do
    it "writes paise back as rupees and quotes where it must" do
      schema = Csv2json.schema(%({"columns": [{"name": "code", "type": "string"}, {"name": "price", "type": "rupees"}]}))
      csv = Csv2json.to_csv(schema, %([{"code": "A, B", "price": 49950}, {"code": "C", "price": null}]))
      csv.should eq(%(code,price\n"A, B",499.50\nC,\n))
    end
  end
end
crystal spec on csv2json Python · spec_session.py
from session import run

# csv2json's specs: the conversion of cells, the line of each record, the errors and the CSV written back.
run("""
cd csv2json
crystal spec
""")

Output

$ cd csv2json
$ crystal spec
........

Finished in …
8 examples, 0 failures, 0 errors, 0 pending

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 spec_session.py

The release build of csv2json Python · release_session.py
from session import run

# The release build of csv2json: its size and the libraries it loads when it starts.
run("""
cd csv2json
shards build csv2json --release
wc -c bin/csv2json
otool -L bin/csv2json
""")

Output

$ cd csv2json
$ shards build csv2json --release
I: Dependencies are satisfied
I: Building: csv2json
$ wc -c bin/csv2json
 1296728 bin/csv2json
$ otool -L bin/csv2json
bin/csv2json:
	/usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)

This output changes from run to run: the program file keeps the full paths of the folders it was built from, so the sizes change a little from one computer to another

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 release_session.py

All eight examples passed. shards build csv2json --release built the converter alone, 1,296,728 bytes, and like the first program of this mini-course it loads only libiconv and libSystem from macOS: PCRE2, which its regular expressions need, and the garbage collector were linked in from the release’s embedded/lib.

Checkpoint

Check: The way back, the specs and the release build

  1. Question 1 of 2 back.csv wrote 499.50 where books.csv had 499.5. Is the round trip broken?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No: the JSON is the canonical form, and back.csv gave the same JSON, which diff confirmed

    Say which form is the reference. Here a round trip from CSV is checked on the JSON, and a round trip from JSON would give the same JSON again.

  2. Question 2 of 2 Which libraries does the release build of csv2json load when it starts?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: libiconv and libSystem, both part of macOS

    As with the first program of the mini-course, a release build is one file that runs on a Mac without Crystal: 1,296,728 bytes for csv2json.

Practise: csv2json again, in Python

Writing the converter a second time, in another language, gives the first one something to be checked against, a test oracle: A second, trusted way to work out the expected results of tests, such as a short program written straight from the specification, used to check an implementation on many inputs.. When the two print different bytes for the same file, either one of them breaks a rule or a rule can be read two ways, and both are worth finding. The lab takes three steps, and each sample test is a line that the Crystal tool printed in the sessions above:

Code lab · 3 steps

Build csv2json again, in Python

Write the cell rules, the records with their start lines and the whole run, so that the Python version prints exactly what csv2json printed for books.csv, stock.csv and renamed.csv.

  1. Convert one cell by its column's type To do
  2. Split CSV into records with their start lines To do
  3. Run csv2json from schema to JSON To do

Exercise · Easy · Python

Convert one cell by its column's type

Write convert(cell, kind) in cells.py, rule 5 of csv2json's specification, for the four kinds of column:

  • "string": the cell as it is.
  • "int": an optional - and the ASCII digits 0 to 9, nothing else, not even a space or a +; the value must fit a 64-bit integer, from -263 to 263 - 1.
  • "rupees": ASCII digits, optionally a point and one or two more digits, as whole paise: "349.5" is 34950. Work with integers only.
  • "bool": "true" or "false".

An empty cell gives None, except in a string column, where it gives "". A cell that breaks its rule raises ValueError with the reason csv2json prints, such as "ninety-six" is not an int: the cell in double quotes, as json.dumps(cell, ensure_ascii=False) writes it, then is not an int, is not rupees or is not a bool.

Starter code · cells.py

def convert(cell, kind):
    """The value of cell in a column of this kind, or ValueError with the reason it is not one."""
    if kind == "int":
        return int(cell)
    if kind == "rupees":
        return int(float(cell) * 100)
    if kind == "bool":
        return cell == "true"
    return cell
The sample tests · test_cells.py
from cells import convert


def reason(cell, kind):
    """The message of the ValueError that convert raises, or None when it raises none."""
    try:
        convert(cell, kind)
    except ValueError as error:
        return str(error)
    return None


def test_the_prices_of_books_csv():
    """349.00, 499.5, 149 and 0.5 became 34900, 49950, 14900 and 50 paise"""
    assert [convert(c, "rupees") for c in ["349.00", "499.5", "149", "0.5"]] == [34900, 49950, 14900, 50]


def test_prices_that_break_the_rule():
    """three decimals, a currency sign and a thousands separator"""
    assert reason("₹ 299", "rupees") == '"₹ 299" is not rupees'
    assert reason("149.505", "rupees") == '"149.505" is not rupees'
    assert reason("1,499", "rupees") == '"1,499" is not rupees'


def test_a_price_that_a_float_gets_wrong():
    """4.35 rupees through a float is 434 paise; the rule gives 435"""
    assert convert("4.35", "rupees") == 435


def test_ints():
    """only an optional minus and digits, within 64 bits"""
    assert convert("212", "int") == 212
    assert convert("-7", "int") == -7
    assert reason("ninety-six", "int") == '"ninety-six" is not an int'
    assert reason(" 212", "int") == '" 212" is not an int'
    assert reason("+212", "int") == '"+212" is not an int'
    assert reason("99999999999999999999", "int") == '"99999999999999999999" is not an int'
    assert convert("9223372036854775807", "int") == 9223372036854775807


def test_bools_and_empty_cells():
    """true and false only; an empty cell is null except in a string column"""
    assert convert("true", "bool") is True
    assert convert("false", "bool") is False
    assert reason("yes", "bool") == '"yes" is not a bool'
    assert convert("", "int") is None
    assert convert("", "bool") is None
    assert convert("", "rupees") is None
    assert convert("", "string") == ""
    assert convert("चाय और किताबें", "string") == "चाय और किताबें"
A hint

A regular expression with re.fullmatch states each rule exactly: -?[0-9]+ for an int and ([0-9]+)(?:\.([0-9]{1,2}))? for rupees. For paise, pad the digits after the point to two with ljust(2, "0") and add them to the rupees times 100.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

Split CSV into records with their start lines

Write records(text) in records.py, rule 3 of the specification with the line of rule 6: it returns a list of (line, cells) pairs, one for each record of the CSV text, where cells is the list of the record's cells and line is the line on which the record starts.

  • The records follow RFC 4180, so a quoted cell may hold commas, doubled quotes and line breaks, and records may end with LF or CRLF.
  • A byte-order mark, the character U+FEFF, at the start of the text is dropped.
  • An empty line gives no record, but it still counts as a line.

The sample tests are the records of stock.csv, the export with a byte-order mark and CRLF line breaks, and a file with an empty line.

Starter code · records.py

import csv
import io


def records(text):
    """(line, cells) for each record of the CSV text, line being the line the record starts on."""
    reader = csv.reader(io.StringIO(text))
    return [(reader.line_num, row) for row in reader]
The sample tests · test_records.py
from records import records

STOCK = (
    "code,title,pages,price,in_stock\n"
    "B-101,Monsoon Notes,212,349.00,true\n"
    'B-102,"Notes from\nthe Ghats",180,₹ 299,yes\n'
    "B-103,Tea and Trains,ninety-six,149,true\n"
    "B-104,City of Lakes,348\n"
)

EXPORT = "\ufeffcode,title\r\nB-101,Monsoon Notes\r\nB-102,\"Lakes, Hills and Rivers\"\r\n"


def test_start_lines_of_stock_csv():
    """the record after the quoted line break starts on line 5"""
    assert [line for line, _ in records(STOCK)] == [1, 2, 3, 5, 6]


def test_cells_of_stock_csv():
    """the quoted title keeps its line break, and B-104 has three cells"""
    rows = records(STOCK)
    assert rows[2][1][1] == "Notes from\nthe Ghats"
    assert rows[4] == (6, ["B-104", "City of Lakes", "348"])


def test_byte_order_mark_and_crlf():
    """the export's header starts with code, not with the mark"""
    assert records(EXPORT) == [(1, ["code", "title"]), (2, ["B-101", "Monsoon Notes"]), (3, ["B-102", "Lakes, Hills and Rivers"])]


def test_empty_lines_count_but_give_no_record():
    """the record after an empty line starts on line 3"""
    assert records("code,pages\n\nA,1\n") == [(1, ["code", "pages"]), (3, ["A", "1"])]
A hint

Python's csv.reader(io.StringIO(text)) splits the records correctly, but its line_num is the line where a record ends. Keep your own counter instead: start at 1, and after each record add 1 and the number of "\n" in its cells.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

Run csv2json from schema to JSON

csv2json.py already holds convert and records from the first two steps. Write run(schema_text, csv_text), which returns what csv2json prints, as (stdout, stderr, status):

  • Read the schema with json.loads. A column whose type is not string, int, rupees or bool gives schema: column NAME: unknown type "TYPE" and status 2.
  • The first record must be the schema's column names, in order. Otherwise: header does not match the schema: expected A,B,…, found X,Y,… and status 2.
  • Then every error of rule 6, in file order: line N: expected 5 fields, found 3 for a record of the wrong size and line N, column NAME: REASON for a cell, with the reason convert raises. Any error gives status 1 and an empty stdout.
  • Otherwise stdout is the JSON array, json.dumps(objects, indent=2, ensure_ascii=False), with the keys in the header's order, and status 0.

Every message ends with a line break, and stderr holds one message per line. The sample tests are the runs of the lesson's session.

Starter code · csv2json.py

import csv
import io
import json
import re

INT = re.compile(r"-?[0-9]+")
RUPEES = re.compile(r"([0-9]+)(?:\.([0-9]{1,2}))?")
WORDS = {"int": "an int", "rupees": "rupees", "bool": "a bool"}
TYPES = ("string", "int", "rupees", "bool")


def convert(cell, kind):
    """The value of cell in a column of this kind, or ValueError with the reason it is not one (step 1)."""
    if cell == "":
        return "" if kind == "string" else None
    if kind == "string":
        return cell
    value = None
    if kind == "int" and INT.fullmatch(cell) and -(2**63) <= int(cell) < 2**63:
        value = int(cell)
    elif kind == "rupees" and RUPEES.fullmatch(cell):
        found = RUPEES.fullmatch(cell)
        value = int(found.group(1)) * 100 + int((found.group(2) or "").ljust(2, "0"))
    elif kind == "bool" and cell in ("true", "false"):
        value = cell == "true"
    if value is None:
        raise ValueError(f"{json.dumps(cell, ensure_ascii=False)} is not {WORDS[kind]}")
    return value


def records(text):
    """(line, cells) for each record, line being the line it starts on (step 2)."""
    if text.startswith("\ufeff"):
        text = text[1:]
    rows = []
    line = 1
    for row in csv.reader(io.StringIO(text)):
        if row:
            rows.append((line, row))
        line += 1 + sum(cell.count("\n") for cell in row)
    return rows


def run(schema_text, csv_text):
    """(stdout, stderr, status) of csv2json for a schema and a CSV text."""
    schema = json.loads(schema_text)
    rows = records(csv_text)
    objects = [dict(zip(rows[0][1], cells)) for _, cells in rows[1:]]
    return json.dumps(objects) + "\n", "", 0
The sample tests · test_csv2json.py
from csv2json import run

SCHEMA = (
    '{"columns": [{"name": "code", "type": "string"}, {"name": "title", "type": "string"},'
    ' {"name": "pages", "type": "int"}, {"name": "price", "type": "rupees"}, {"name": "in_stock", "type": "bool"}]}'
)

BOOKS = (
    "code,title,pages,price,in_stock\n"
    "B-101,Monsoon Notes,212,349.00,true\n"
    'B-102,"Lakes, Hills and Rivers",348,499.5,false\n'
    'B-103,"The ""Quiet"" Train",96,149,true\n'
    "B-104,चाय और किताबें,,0.5,\n"
)

BOOKS_JSON = """[
  {
    "code": "B-101",
    "title": "Monsoon Notes",
    "pages": 212,
    "price": 34900,
    "in_stock": true
  },
  {
    "code": "B-102",
    "title": "Lakes, Hills and Rivers",
    "pages": 348,
    "price": 49950,
    "in_stock": false
  },
  {
    "code": "B-103",
    "title": "The \\"Quiet\\" Train",
    "pages": 96,
    "price": 14900,
    "in_stock": true
  },
  {
    "code": "B-104",
    "title": "चाय और किताबें",
    "pages": null,
    "price": 50,
    "in_stock": null
  }
]
"""

STOCK = (
    "code,title,pages,price,in_stock\n"
    "B-101,Monsoon Notes,212,349.00,true\n"
    'B-102,"Notes from\nthe Ghats",180,₹ 299,yes\n'
    "B-103,Tea and Trains,ninety-six,149,true\n"
    "B-104,City of Lakes,348\n"
)

STOCK_ERRORS = """line 3, column price: "₹ 299" is not rupees
line 3, column in_stock: "yes" is not a bool
line 5, column pages: "ninety-six" is not an int
line 6: expected 5 fields, found 3
"""


def test_books_csv():
    """the JSON csv2json printed for books.csv, byte for byte"""
    assert run(SCHEMA, BOOKS) == (BOOKS_JSON, "", 0)


def test_stock_csv():
    """four errors in file order, no JSON, status 1"""
    assert run(SCHEMA, STOCK) == ("", STOCK_ERRORS, 1)


def test_a_header_without_records():
    """an empty array"""
    assert run(SCHEMA, "code,title,pages,price,in_stock\n") == ("[]\n", "", 0)


def test_renamed_header():
    """renamed.csv says name where the schema says title"""
    out, err, status = run(SCHEMA, "code,name,pages,price,in_stock\nB-101,Monsoon Notes,212,349.00,true\n")
    assert (out, status) == ("", 2)
    assert err == "header does not match the schema: expected code,title,pages,price,in_stock, found code,name,pages,price,in_stock\n"


def test_unknown_type():
    """bad-schema.json has the type money"""
    bad = '{"columns": [{"name": "code", "type": "string"}, {"name": "price", "type": "money"}]}'
    assert run(bad, BOOKS) == ("", 'schema: column price: unknown type "money"\n', 2)
A hint

Build each object as a dict in the order of the schema's columns; Python keeps that order and json.dumps writes it. Collect the error messages in a list and decide the status only after the last record.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then the self-check on the five ideas of the project, asked one at a time from the idea your answers show least, each answer with its reason:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Which exit status did csv2json give renamed.csv, whose header says name instead of title?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: 2, an error of the whole file

    Three statuses say three things: 0 converted, 1 records with mistakes, 2 a file or schema that cannot be converted at all.

  2. Question 2 of 10 A second implementation writes the same JSON objects with their keys in alphabetical order. Is it right?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: No: rule 7 puts the keys in the header's order, so the bytes must follow it

    A specification that is meant for black-box tests fixes the bytes, not only the meaning, so any two implementations can be compared file to file.

  3. Question 3 of 10 What does include JSON::Serializable give the class Column?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: A constructor that reads JSON into its typed instance variables, and to_json

    JSON::Serializable is a module that generates the code to read and write JSON for a class's instance variables, using their names and types.

  4. Question 4 of 10 Why does convert return a Bad instead of raising an exception for a bad cell?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: A bad cell is expected data, not a failure of the program, and the union makes every caller handle it

    Keep exceptions for what should not happen, a file that cannot be read or a schema that breaks the rules, and return values for what the data may hold.

  5. Question 5 of 10 In stock.csv, B-102 starts on line 3 and its title holds one line break. On which line does B-103 start?

    Type a number.

    A hint

    Add one for the record and one for each line break inside it.

    Show the answer: Question 5 of 10

    Answer: 5 line

    3 + 1 + 1 = 5: one for the end of B-102 and one for the line break inside its title. csv2json reported the bad pages cell of B-103 on line 5.

  6. Question 6 of 10 A converter forgets to drop the byte-order mark. What happens with export.csv?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: The first header cell is U+FEFF followed by code, so the header check fails, though the file looks right

    text.lchop(BOM) removes the mark before anything is read, which is why export.csv gave the same JSON as books.csv.

  7. Question 7 of 10 How many paise did csv2json make of the price 499.5?

    Type a number.

    A hint

    The JSON of books.csv shows it for B-102.

    Show the answer: Question 7 of 10

    Answer: 49950 paise

    499 rupees times 100, plus the digits after the point padded to two, 50: 49,950 paise, exactly, with no float.

  8. Question 8 of 10 stock.csv had yes in the in_stock column. What would make csv2json accept it?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Nothing, while the specification says a bool is true or false; change the data or the specification

    A converter that guesses would accept yes, Y and 1 in one tool and not in another. A written rule makes every implementation refuse or accept the same cells.

  9. Question 9 of 10 How many examples did crystal spec run for csv2json?

    Type a number.

    A hint

    The last line of crystal spec's output says it.

    Show the answer: Question 9 of 10

    Answer: 8 examples

    8 examples, 0 failures: three for convert, two for records, two for to_json and one for to_csv.

  10. Question 10 of 10 The Python version of the lab prints a different line for one file than the Crystal tool. What do you do first?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Read the specification for that case: one of the two is wrong, or the specification is unclear

    Two implementations from one specification turn every disagreement into a question for the specification, the cheapest way to find what it left open.

Interview questions

Warm-up (fresher to mid-level): Why does csv2json report the line where a record starts, and how does it work it out?

Because that is where a person looks: an editor shows lines, and a record with a line break inside a quoted cell spans several of them, so its position among the records is not its line. In stock.csv, B-103 is the fourth record but starts on line 5, and the error says line 5. Crystal’s CSV reader returns records without lines, so csv2json counts them itself: it starts at 1 and, after each record, adds one for the record’s own line break and one for every line break inside its cells. The rule is written into the specification, because readers differ: Python’s csv.reader.line_num counts the lines read so far, which after a record of two lines is the line where it ends, 4 for B-102 where csv2json says 3.

Two longer questions, on two tools that disagree about line numbers and on an export too large for memory:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to senior

    csv2json and an older Python script both check the same supplier file, and for some rows they report different line numbers for the same mistake: csv2json says line 41, the script says line 42. Which one is right, and how do you settle it for good?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    A monthly export of 3 GB must go through csv2json on a machine with 2 GB of memory, and the run is killed. What in the design holds the whole file, and how do you change it without breaking the rule that a file with errors gives no JSON?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which line does csv2json name for an error in a record?

    The line where the record starts, the one an editor shows. A quoted cell with a line break makes a record span lines: B-103 is the fourth record but starts on line 5.

  2. How does csv2json work out where each record starts?

    It begins at 1 and, after each record, adds one for its own line break and one per line break inside its cells; Crystal's CSV reader returns records without lines.

  3. Why did Python's csv.reader give line 4 for B-102 where csv2json gives 3?

    line_num counts the lines read so far, which after a two-line record is where it ends. Neither is wrong, so the specification states which line is reported.

  4. What does csv2json do with a file that has four bad cells?

    It reports all four in file order, as line N, column NAME: … on standard error, writes no JSON at all and ends with exit status 1.

  5. Why does csv2json drop a byte-order mark before it checks the header?

    Some programs begin UTF-8 files with U+FEFF. Left in, the first column would be named \u{FEFF}code, and a header that looks right in every editor would fail.

  6. Which int cells does Crystal's to_i64? accept that csv2json's rule refuses?

    " 212" with its space, and "+212". csv2json first tests an int cell against /\A-?[0-9]+\z/ and only then converts it.

  7. Why does csv2json never put a price through a float?

    4.35 as a float times 100 is 434.99999999999994, which cuts to 434, a paisa short. It reads rupees and up to two decimals as two integers, so 349.5 is exactly 34,950.

  8. Why is the JSON, and not the CSV, the canonical form of csv2json's data?

    json2csv writes 499.5 back as 499.50 and 149 as 149.00, because the JSON holds paise. Converted back, the JSON is identical; the CSV text is not.

Key takeaways

  • A specification written first fixes the schema, the cell rules, the start line of each record, the errors and the JSON’s layout, so that two implementations print the same bytes.
  • JSON::Serializable reads the schema into typed classes and names a missing field itself, and Value | Bad makes every caller handle a cell that breaks its rule.
  • A record’s line is where it starts: add one and the line breaks inside its cells after each record, and drop a byte-order mark before the header check.
  • Conversions of a library accept more than a specification allows, and a price through a float lost a paisa, so csv2json tests each cell with its own rule and counts rupees in whole paise.
  • json2csv shows that the JSON is the canonical form, the specs test each rule with the cells that break it, and the release build loads only two libraries of macOS.

Terms in this lesson

byte-order mark
The character U+FEFF at the very start of a text file, which some programs write before UTF-8 text. A reader that does not drop it takes it as part of the first value, so the first column of a CSV header no longer matches its name.
canonical form
The one form of some data that a specification declares the reference, such as csv2json's JSON with prices in paise. Other forms, such as the CSV that json2csv writes back, may differ in their text and still hold the same data.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.