{"data": {"search_index": {"totalCount": 3292, "pageInfo": {"hasNextPage": true, "endCursor": "blog~2Edb~2Fentries,http~3A~2F~2Fsimonwillison~2Enet~2F2017~2FNov~2F17~2Fpaul-ford~2F~23atom-tag"}, "nodes": [{"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Dec/10/datasette/#atom-tag", "title": "Datasette 0.14: customization edition", "timestamp": "2017-12-10T01:55:26+00:00", "is_public": 1, "search_1": "
Datasette 0.14: customization edition
\nI just released the latest version of Datasette with a strong theme of customization: Datasette now supports custom templates and CSS styling hooks, and the metadata format has been expanded to allow per-database and per-table source/license/description information. You can also now define named canned queries which will be packaged up with your data.
", "search_2": null, "search_3": null, "category": null}, {"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Dec/11/vice/#atom-tag", "title": "VICE News Police Shootings in Datasette", "timestamp": "2017-12-11T22:47:11+00:00", "is_public": 1, "search_1": "VICE News Police Shootings in Datasette
\nVICE News collected data on both fatal and nonfatal police shootings from the 50 largest local police departments in the United States. They released the data under a CC BY-NC-SA 4.0 license so I've converted it to SQLite and loaded it into Datasette.
\n\n ", "search_2": null, "search_3": null, "category": null}, {"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Dec/12/location-time-zone-api/#atom-tag", "title": "Building a location to time zone API with SpatiaLite, OpenStreetMap and Datasette", "timestamp": "2017-12-12T15:52:20+00:00", "is_public": 1, "search_1": "Given a latitude and longitude, how can we tell what time zone that point lies within? Here\u2019s how I built a simple JSON API to answer that question, using a combination of data from OpenStreetMap, the SpatiaLite extension for SQLite and my Datasette API tool.
\nYou can try the API out here: feed it a latitude and longitude and it will return the corresponding time zone ID: https://timezones-api.now.sh/timezones/by_point
\nSome examples:
\nI was first introduced to Eric Muller\u2019s tz_world shapefile by Nick Williams at Eventbrite, who used it to build us an internal time zone lookup API on top of MySQL\u2019s geospatial data types. Eric\u2019s project is no longer updated and he recommends Evan Siroky\u2019s timezone-boundary-builder project as an alternative, which derives time zone shapes from OpenStreetMap and makes the resulting data available under the Open Database License. The shapefile itself can be downloaded from the GitHub releases page for the project.
\nSpatiaLite is a powerful open source extension for SQLite, which adds a comprehensive suite of geospatial functions - including the ability to ingest shapefiles, convert them into geometries and run point within queries against the resulting shapes.
\nThe easiest way to get it running on OS X is via Homebrew:
\n$ brew install spatialite-tools\n$ brew install gdal\n
\nHaving installed SpatiaLite, we can ingest the shapefile using .loadshp combined_shapefile timezones CP1252 23032
- here\u2019s the full process, from downloading the shapefile to ingesting it into a new SQLite database file called timezones.db
:
$ wget https://github.com/evansiroky/timezone-boundary-builder/releases/download/2017c/timezones.shapefile.zip\n$ unzip timezones.shapefile.zip\n$ cd dist\n$ spatialite timezones.db\nSpatiaLite version ..: 4.3.0a Supported Extensions:\n...\nEnter SQL statements terminated with a ";"\nspatialite> .loadshp combined_shapefile timezones CP1252 23032\n========\nLoading shapefile at 'combined_shapefile' into SQLite table 'timezones'\n\nBEGIN;\nCREATE TABLE "timezones" (\n"PK_UID" INTEGER PRIMARY KEY AUTOINCREMENT,\n"tzid" TEXT);\nSELECT AddGeometryColumn('timezones', 'Geometry', 23032, 'MULTIPOLYGON', 'XY');\nCOMMIT;\n\nInserted 414 rows into 'timezones' from SHAPEFILE\n========\nspatialite> \n
\nLet\u2019s try it out with a query. Here\u2019s the SQL needed to find the time zone for a point in Tokyo:
\nselect tzid\nfrom\n timezones\nwhere\n within(GeomFromText('POINT(139.7819661 35.6631424)'), timezones.Geometry);\n
\nLet\u2019s run that in SpatiaLite and see what we get:
\nspatialite> select tzid\n ...> from\n ...> timezones\n ...> where\n ...> within(GeomFromText('POINT(139.7819661 35.6631424)'), timezones.Geometry);\nAsia/Tokyo\n
\nLooks good so far! How long is it taking though? We can find out by running .timer on
in the spatialite prompt:
spatialite> .timer on\nspatialite> select tzid\n ...> from\n ...> timezones\n ...> where\n ...> within(GeomFromText('POINT(139.7819661 35.6631424)'), timezones.Geometry);\nAsia/Tokyo\nCPU Time: user 0.108479 sys 0.064778\n
\nThat\u2019s a tenth of a second, or 100ms. Fast, but not brilliant.
\nIt turns out SpatiaLite includes support for spatial indices, based on SQLite\u2019s R*Tree module. R-Tree indexes can massively accelerate boundary box searches. Our searches are a lot more complex than that, acting as they do against extremely complex polygon shapes - but we can use a boundary box search to dramatically reduce the candidates we need to consider. Let\u2019s create an index against our Geometry column:
\nSELECT CreateSpatialIndex('timezones', 'geometry');\n
\nTo take advantage of this index, we need to expand our original SQL to first filter by geometries where their bounding box contains the point we are searching for. SpatiaLite has created an index table called idx_timezones_Geometry
against which we can run an R-Tree optimized query. Here\u2019s the SQL we will use:
select tzid\nfrom\n timezones\nwhere\n within(GeomFromText('POINT(139.7819661 35.6631424)'), timezones.Geometry)\n and rowid in (\n SELECT pkid FROM idx_timezones_Geometry\n where xmin < 139.7819661\n and xmax > 139.7819661\n and ymin < 35.6631424\n and ymax > 35.6631424\n );\n
\nHow does this affect our performance?
\nspatialite> select tzid\n ...> from\n ...> timezones\n ...> where\n ...> within(GeomFromText('POINT(139.7819661 35.6631424)'), timezones.Geometry)\n ...> and rowid in (\n ...> SELECT pkid FROM idx_timezones_Geometry\n ...> where xmin < 139.7819661\n ...> and xmax > 139.7819661\n ...> and ymin < 35.6631424\n ...> and ymax > 35.6631424\n ...> );\nAsia/Tokyo\nCPU Time: user 0.001541 sys 0.000111\n
\nFrom 100ms down to 1.5ms - nearly a 70x speedup! Not bad at all.
\nNow that we have a fast SQL query for finding a time zone for a latitude and longitude we can use Datasette to turn it into a JSON API.
\nThe simplest way to do that looks like this:
\ndatasette timezones.db \\\n --load-extension=/usr/local/lib/mod_spatialite.dylib\n
\nThis will start Datasette on port 8001 and load the SpatiaLite extension. You can then navigate to localhost:8001/timezones
in your browser and paste in the SQL query\u2026 which should look something like this:
This works (click the JSON link to get the result as JSON) but it\u2019s a little inconvenient to use: you have to construct a URL with the same latitude and longitude repeated in multiple places.
\nWe can improve things using Datasette\u2019s support for SQLite named parameters. Here\u2019s that same SQL query using parameters instead of hard-coded latitude and longitude points:
\nselect tzid\nfrom\n timezones\nwhere\n within(GeomFromText('POINT(' || :longitude || ' ' || :latitude || ')'), timezones.Geometry)\n and rowid in (\n SELECT pkid FROM idx_timezones_Geometry\n where xmin < :longitude\n and xmax > :longitude\n and ymin < :latitude\n and ymax > :latitude)\n
\nIf you paste this into Datasette it will detect the named parameters and turn them into URL querystring parameters hooked up (in thu UI) to HTML form fields.
\n\nTo save us from having to include the full SQL in the URL every time we call our new API, let\u2019s take advantage of a new feature introduced in Datasette 0.14: canned queries. These are named, pre-packaged queries that can be defined in a metadata.json
file. The file looks like this:
{\n "title": "OpenStreetMap Time Zone Boundaries",\n "license": "ODbL",\n "license_url": "http://opendatacommons.org/licenses/odbl/",\n "source": "timezone-boundary-builder",\n "source_url": "https://github.com/evansiroky/timezone-boundary-builder",\n "databases": {\n "timezones": {\n "queries": {\n "by_point": "select tzid\\nfrom\\n timezones\\nwhere\\n within(GeomFromText('POINT(' || :longitude || ' ' || :latitude || ')'), timezones.Geometry)\\n and rowid in (\\n SELECT pkid FROM idx_timezones_Geometry\\n where xmin < :longitude\\n and xmax > :longitude\\n and ymin < :latitude\\n and ymax > :latitude\\n )"\n }\n }\n }\n}\n
\nThe canned query is defined as the by_point
key in the queries
nested object. I\u2019m also adding license and source information here for the project, because it\u2019s good manners.
We can try this in Datasette on our local machine like so:
\ndatasette timezones.db -m metadata.json \\\n --load-extension=/usr/local/lib/mod_spatialite.dylib \n
\nNow visiting localhost:8001/timezones/by_point
will provide the interface for the query - and adding .json
to the URL will turn it into an API.
If you browse around in Datasette you\u2019ll quickly run into a rather unfortunate problem. The localhost:8001/timezones/timezones
page, which shows the first 100 rows in the table, takes a shockingly long time to load. When it eventually does load you\u2019ll see why: each record includes an enormous binary string containing its the geometry. On my machine just that one page weighs in at 62MB of HTML!
This is bad: rendering that much HTML in one go can block the event loop and cause the application to become unresponsive. That giant blob of binary data isn\u2019t exactly useful for humans, either.
\nWe can make some major improvements here using another Datasette 0.14 feature: custom templates. Let\u2019s start with a replacement template that shows just the length of the binary string instead of attempting to render it.
\nWe\u2019ll do that by over-riding the _rows_and_columns.html
include template, which is used by Datasette on both the table page and the page used to display individual rows. Since we only want to over-ride this template for one particular table we\u2019ll create a file called _rows_and_columns-timezones-timezones.html
- the file name means this will only over-ride the template timezones
table in our timezones
database. Here\u2019s our new template:
<table>\n <thead>\n <tr>\n {% for column in display_columns %}\n <th scope="col">{{ column }}</th>\n {% endfor %}\n </tr>\n </thead>\n <tbody>\n {% for row in display_rows %}\n <tr>\n {% for cell in row %}\n <td>\n {% if cell.column == 'Geometry' %}\n {{ cell.value|length }} bytes\n {% else %}\n {{ cell.value }}\n {% endif %}\n </td>\n {% endfor %}\n </tr>\n {% endfor %}\n </tbody>\n</table>\n
\nIf we put that in a directory called templates/
we can tell Datasette to use it like this:
datasette timezones.db -m metadata.json \\\n --load-extension=/usr/local/lib/mod_spatialite.dylib \\\n --template-dir=templates/\n
\nOur localhost:8001/timezones/timezones
page now looks like this:
But wouldn\u2019t it be neat if we could see the actual shapes of these time zones? It turns out that\u2019s actually pretty easy, using the combination of GeoJSON and the Leaflet mapping library.
\nGeoJSON is a neat, widely supported standard for encoding geographic information such as polygon shapes as JSON. SpatiaLite ships with built-in GeoJSON support in the form of the AsGeoJSON
SQL function. We can use that function to turn any of our time zone geometries into a GeoJSON string:
select AsGeoJSON(Geometry) from timezones where tzid = 'Asia/Tokyo';\n
\nIf you run that with Datasette you\u2019ll get back a string of GeoJSON. You can paste that into geojson.io to instantly visualize it.
\nThe Leaflet mapping library supports GeoJSON out of the box. We can construct a custom row.html
template for our Datasette that loads Leaflet from unpkg.com, uses fetch()
to execute the AsGeoJSON
query and renders the result in a map on the page. Here\u2019s the result:
And here\u2019s the row-timezones-timezones.html
template:
{% extends "row.html" %}\n{% block extra_head %}\n<link rel="stylesheet" href="https://unpkg.com/leaflet@1.2.0/dist/leaflet.css" integrity="sha512-M2wvCLH6DSRazYeZRIm1JnYyh22purTM+FDB5CsyxtQJYeKq83arPe5wgbNmcFXGqiSH2XR8dT/fJISVA1r/zQ==" crossorigin=""/>\n<script src="https://unpkg.com/leaflet@1.2.0/dist/leaflet.js" integrity="sha512-lInM/apFSqyy1o6s89K4iQUKg6ppXEgsVxT35HbzUupEVRh2Eu9Wdl4tHj7dZO0s1uvplcYGmt3498TtHq+log==" crossorigin=""></script>\n<style>\n#map {\n margin-top: 1em;\n width: 100%;\n height: 400px;\n}\n</style>\n{% endblock %}\n\n{% block content %}\n{{ super() }}\n<div id="map"></div>\n<script>\nvar pk = location.pathname.split('/').slice(-1)[0];\nvar tiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {\n maxZoom: 19,\n detectRetina: true,\n attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Points © 2012 LINZ'\n});\nvar sql = 'select AsGeoJSON(Geometry) from timezones where PK_UID = ' + pk;\nfetch('/timezones.json?sql=' + encodeURIComponent(sql)).then(r => r.json()).then(d => {\n var map = L.map('map', {layers: [tiles]});\n var geoJSON = JSON.parse(d.rows[0][0]);\n var layer = L.geoJSON(geoJSON)\n layer.addTo(map);\n map.fitBounds(layer.getBounds());\n});\n</script>\n{% endblock %}\n
\nNormally we would use the datasette publish
command to publish our database to Heroku or Zeit Now, but the SpatiaLite dependency means that won\u2019t work for this case. Instead, we need to construct a custom Dockerfile that builds the SpatiaLite module.
Since we\u2019re using Docker, we may as well have the Dockerfile download the shapefiles and build the SpatiaLite database for us all in one go. Here\u2019s a Dockerfile that does exactly that:
\nFROM python:3.6-slim-stretch\n\nRUN apt update\nRUN apt install -y python3-dev gcc spatialite-bin libsqlite3-mod-spatialite wget unzip\n\nRUN pip install https://github.com/simonw/datasette/archive/master.zip\n\n# Download and import the shapefiles\nRUN wget https://github.com/evansiroky/timezone-boundary-builder/releases/download/2017c/timezones.shapefile.zip \\\n && unzip timezones.shapefile.zip && \\\n cd dist && \\\n echo ".loadshp combined_shapefile timezones CP1252 23032\\nSELECT CreateSpatialIndex('timezones', 'geometry');" | spatialite timezones.db && \\\n mv timezones.db .. && \\\n cd .. && rm -rf dist && rm timezones.shapefile.zip\n\nENV SQLITE_EXTENSIONS /usr/lib/x86_64-linux-gnu/mod_spatialite.so\n\nADD metadata.json metadata.json\n\nADD templates templates\n\nRUN datasette inspect timezones.db --inspect-file inspect-data.json\n\nEXPOSE 8001\n\nCMD ["datasette", "serve", "timezones.db", "--host", "0.0.0.0", "--cors", "--port", "8001", "--inspect-file", "inspect-data.json", "-m", "metadata.json", "--template-dir", "templates"]\n
\nThe full code, including the supporting templates, can be found in simonw/timezones-api on GitHub.
\nIf you have Docker installed (Docker for Mac is a one-click install package these days, it\u2019s impressively painless) you can build the container like this:
\ndocker build . -t timezones-api\n# Wait for the image to build\ndocker run -p 8001:8001 timezones-api\n
\nThen you can visit http://localhost:8001/
to see your freshly built Datasette in your browser.
The easiest way to publish it online is using Zeit Now. Simply run the now
command in the directory containing the Dockerfile and Zeit will upload the entire directory, build the container in the cloud and deploy it with a fresh URL. It\u2019s by far the easiest Docker deployment environment I\u2019ve ever used.
Now can even deploy directly from a public GitHub repository\u2026 so you can deploy your own copy of the API by running the following command:
\n$ now simonw/timezones-api\n> Didn't find directory. Searching on GitHub...\n> Deploying GitHub repository "simonw/timezones-api" under simonw\n> Ready! https://simonw-timezones-api-fbihjcbnog.now.sh (copied to clipboard) [2s]\n> Initializing\u2026\n
\nHopefully this has helped illustrate the ease with which Datasette, SpatiaLite and canned queries can be used to create and publish geospatial APIs. Thanks to projects like OpenStreetMap the world is full of high quality open geospatial data. Go build something cool with it!
", "search_2": null, "search_3": null, "category": null}, {"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Nov/13/csvs-to-sqlite/#atom-tag", "title": "simonw/csvs-to-sqlite", "timestamp": "2017-11-13T06:49:45+00:00", "is_public": 1, "search_1": "\nI built a simple tool for bulk converting multiple CSV files into a SQLite database.
", "search_2": null, "search_3": null, "category": null}, {"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Nov/13/datasette/#atom-tag", "title": "Datasette: instantly create and publish an API for your SQLite databases", "timestamp": "2017-11-13T23:49:28+00:00", "is_public": 1, "search_1": "I just shipped the first public version of datasette, a new tool for creating and publishing JSON APIs for SQLite databases.
\nYou can try out out right now at fivethirtyeight.datasettes.com, where you can explore SQLite databases I built from Creative Commons licensed CSV files published by FiveThirtyEight. Or you can check out parlgov.datasettes.com, derived from the parlgov.org database of world political parties which illustrates some advanced features such as SQLite views.
\n\n\n\nOr you can try it out on your own machine. If you run OS X and use Google Chrome, try running the following:
\npip3 install datasette\ndatasette ~/Library/Application\\ Support/Google/Chrome/Default/History\n
\nThis will start a web server on http://127.0.0.1:8001/ displaying an interface that will let you browse your Chrome browser history, which is conveniently stored in a SQLite database.
\nGot a SQLite database you want to share with the world? Provided you have Zeit Now set up on your machine, you can publish one or more databases with a single command:
\ndatasette publish now my-database.db\n
\nThe above command will whir away for about a minute and then spit out a URL to a hosted version of datasette with your database (or databases) ready to go. This is how I\u2019m hosting the fivethirtyeight and parlgov example datasets, albeit on a custom domain behind a Cloudflare cache.
\nEverything datasette can do is driven by URLs. Queries can produce responsive HTML pages (I\u2019m using a variant of this responsive tables pattern for smaller screens) or with the .json
or .jsono
extension can produce JSON. All JSON responses are served with an Access-Control-Allow-Origin: *
HTTP header, meaning you can query them from any page.
You can try that right now in your browser\u2019s developer console. Navigate to http://www.example.com/ and enter the following in the console:
\nfetch(\n "https://fivethirtyeight.datasettes.com/fivethirtyeight-2628db9/avengers%2Favengers.jsono"\n).then(\n r => r.json()\n).then(data => console.log(\n JSON.stringify(data.rows[0], null, ' ')\n))\n
\nYou\u2019ll see the following:
\n{\n "rowid": 1,\n "URL": "http://marvel.wikia.com/Henry_Pym_(Earth-616)",\n "Name/Alias": "Henry Jonathan \\"Hank\\" Pym",\n "Appearances": 1269,\n "Gender": "MALE",\n "Full/Reserve Avengers Intro": "Sep-63",\n "Year": 1963,\n "Years since joining": 52,\n ...\n}\n
\nSince the API sits behind Cloudflare with a year-long cache expiry header, responses to any query like this should be lightning-fast.
\nDatasette supports a limited form of filtering based on URL parameters, inspired by Django\u2019s ORM. Here\u2019s an example: by appending ?CLOUDS=1&MOUNTAINS=1&BUSHES=1
to the FiveThirtyEight dataset of episodes of Bob Ross\u2019 The Joy of Painting we can see every episode in which Bob paints clouds, bushes AND mountains:
And here\u2019s the same episode list as JSON.
\nThe most exciting feature of datasette is that it allows users to execute arbitrary SQL queries against the database. Here\u2019s a convoluted Bob Ross example, returning a count for each of the items that can appear in a painting.
\nDatasette has a number of limitations in place here: it cuts off any SQL queries that take longer than a threshold (defaulting to 1000ms) and it refuses to return more than 1,000 rows at a time - partly to avoid too much JSON serialization overhead.
\nDatasette also blocks queries containing the string PRAGMA
, since these statements could be used to modify database settings at runtime. If you need to include PRAGMA
in an argument to a query you can do so by constructing a prepared statement:
select * from [twitter-ratio/senators] where "text" like :q\n
\nYou can then construct a URL that incorporates both the SQL and provides a value for that named argument, like this: https://fivethirtyeight.datasettes.com/fivethirtyeight-2628db9?sql=select+rowid%2C+*+from+[twitter-ratio%2Fsenators]+where+\u201ctext\u201d+like+%3Aq&q=%25pragmatic%25 - which returns tweets by US senators that include the word \u201cpragmatic\u201d.
\nA key feature of datasette is that the API it provides is very deliberately read-only. This provides a number of interesting benefits:
\ndatasette package
command can be used to build a Docker image that bundles the database files and the datasette application together. If you need to scale to handle vast amounts of traffic, just deploy a bunch of extra containers and load-balance between them.Datasette is built on top of the Sanic asynchronous Python web framework (see my previous notes), and makes extensive use of Python 3\u2019s async/await statements. Since SQLite doesn\u2019t yet have an async Python module all interactions with SQLite are handled inside a thread pool managed by a concurrent.futures.ThreadPoolExecutor.
\nThe CLI is implemented using the Click framework. This is the first time I\u2019ve used Click and it was an absolute joy to work with. I enjoyed it so much I turned one of my Jupyter notebooks into a Click script called csvs-to-sqlite and published it to PyPI.
\n\nThis post is being discussed on a Hacker News.
", "search_2": null, "search_3": null, "category": null}, {"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Nov/14/datasette-for-polar-bears/#atom-tag", "title": "Datasette for Polar Bears", "timestamp": "2017-11-14T05:41:30+00:00", "is_public": 1, "search_1": "\nI found a fun dataset of Polar Bear ear tag tracking data put out by the USGS Alaska Science Center and deployed it using datasette in just a couple of minutes - here\u2019s how I did it.
", "search_2": null, "search_3": null, "category": null}, {"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Nov/14/datasettes/#atom-tag", "title": "Datasettes \u00b7 simonw/datasette", "timestamp": "2017-11-14T07:39:41+00:00", "is_public": 1, "search_1": "Datasettes \u00b7 simonw/datasette
\nI'm collecting examples of datasette-powered APIs on the project wiki.
", "search_2": null, "search_3": null, "category": null}, {"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Nov/16/datasette-release/#atom-tag", "title": "Datasette 0.12", "timestamp": "2017-11-16T15:55:35+00:00", "is_public": 1, "search_1": "\nI just released v0.12 of Datasette. The most exciting new feature is the ability to display a UI for editing named parameters - so you can construct an arbitrarily complex SQL query, include some named parameters and then link directly to it in Datasette to provide a simple interface for changing those parameters. An example involving Australian dogs is included in the release notes.
", "search_2": null, "search_3": null, "category": null}, {"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Nov/16/recent-releases/#atom-tag", "title": "Plotly Falcon SQL Client and the Remarkable Datasette SQLite2API Generator", "timestamp": "2017-11-16T03:04:20+00:00", "is_public": 1, "search_1": "Plotly Falcon SQL Client and the Remarkable Datasette SQLite2API Generator
\nLovely write-up of Datasette by Tony Hirst. I\u2019m particularly excited about its applications in the world of data journalism so it\u2019s great to see it starting to get attention from people in that sphere.
", "search_2": null, "search_3": null, "category": null}, {"type": "blog.db/entries", "key": "http://simonwillison.net/2017/Nov/17/paul-ford/#atom-tag", "title": "Quoting Paul Ford", "timestamp": "2017-11-17T01:10:49+00:00", "is_public": 1, "search_1": "If you\u2019re a public data provider\u2014and many large NGOs, government organizations, cultural organizations, historical archives, media organizations, medical orgs, and academic institutions are exactly that\u2014you can publish gigabytes of data, and make it available as an API, and make it easy to browse on the web, too, with extremely low effort. Put it into SQLite, point this little guy at it, and you\u2019ve just radically increased the accessibility and utility of your data. Because messing around in SQL from a web browser is orders of magnitude more immediately useful than downloading a CSV, processing it, and figuring out what comes next.
— Paul Ford", "search_2": null, "search_3": null, "category": null}]}}}