Your first map from files¶
This tutorial is for someone who has never used Mosaica. We'll take the 29,935 places GeoNames lists for Ireland and put them on a map in your browser, where you can search them by name and filter them by kind of place and by population.
This tutorial should take about 30 minutes to complete. You'll need sudo, git, curl, unzip,
openssl, Python 3, Node.js 22.15 or later with npm, and a web browser, on Linux or macOS.
What we'll do¶
- Install
mosaica. - Download the GeoNames file for Ireland and convert it to Parquet.
- Write a declaration that describes the data, and validate it with
mosaica check. - Build the data into a deployment with
mosaica build. - Start the server with
mosaica serve. - Open the map in a browser and filter it.
Install Mosaica¶
Download mosaica from Mosaica's latest release, unpack it and put it on your PATH. These
commands are for Linux on x86_64. On another machine, write its target from the table on the
install page in place of x86_64-unknown-linux-gnu.
curl -LO https://github.com/mosaicadb/mosaica/releases/latest/download/mosaica-x86_64-unknown-linux-gnu.tar.gz
tar xzf mosaica-x86_64-unknown-linux-gnu.tar.gz
sudo install -m 0755 mosaica-x86_64-unknown-linux-gnu/mosaica /usr/local/bin/mosaica
Check that it runs.
mosaica --version
mosaica 0.2.0 (…)
Get the data¶
GeoNames is a free gazetteer of the world's place names, published as one file per country. Make a directory for the project and download the file for Ireland.
mkdir ~/ireland && cd ~/ireland
curl -sO https://download.geonames.org/export/dump/IE.zip
unzip IE.zip
Archive: IE.zip
inflating: readme.txt
inflating: IE.txt
IE.txt has one place per line, with 19 columns separated by tabs and no header row. readme.txt
explains what each column holds. Have a look at the first line.
head -n 1 IE.txt
2635367 Tullyrossmearan Tullyrossmearan Tullyrosmearn,Tullyrossmearan 54.33333 -7.98333 P PPL IE GB C 14 0 53 Europe/Dublin 2015-05-15
That's Tullyrossmearan, in County Leitrim. The line starts with its GeoNames id, followed by its
name, the same name in plain ASCII, and a list of other spellings. Then come its latitude and
longitude, and a P that marks it as a populated place. Further along, its population is given as
0.
A population of 0 means GeoNames doesn't have a figure, which is true of most places here. Only 1,119 of the 29,935 have a population at all. That will matter when we come to filter by it.
For this demo we'll keep five of the 19 columns: the name, the latitude and longitude, the class of place and the population. We'll leave the GeoNames id behind. Mosaica doesn't need one to build a map, and when we check the declaration we'll see what an id column would have changed.
Convert it to Parquet¶
Mosaica's build reads Parquet, an efficient file format for tables. A Parquet file records the name and type of
every column inside the file itself, so a program can see what columns it has without reading any
rows. mosaica check makes use of that later on.
We'll do the conversion with pyarrow, a Python library for Parquet. Make a Python environment and install pyarrow in it.
python3 -m venv .venv
source .venv/bin/activate
pip install pyarrow
Save this script as convert.py.
import pyarrow as pa
import pyarrow.csv as csv
import pyarrow.parquet as pq
columns = [
"geonameid", "name", "asciiname", "alternatenames", "latitude", "longitude",
"feature_class", "feature_code", "country_code", "cc2", "admin1_code",
"admin2_code", "admin3_code", "admin4_code", "population", "elevation", "dem",
"timezone", "modification_date",
]
table = csv.read_csv(
"IE.txt",
read_options=csv.ReadOptions(column_names=columns),
parse_options=csv.ParseOptions(delimiter="\t", quote_char=False),
convert_options=csv.ConvertOptions(
column_types={"population": pa.int64()},
),
)
points = pa.table({
"lon": table["longitude"],
"lat": table["latitude"],
"name": table["name"],
"feature_class": table["feature_class"],
"population": table["population"],
})
pq.write_table(points, "points.parquet")
print(f"wrote {points.num_rows} places to points.parquet")
Because the file has no header, the script supplies the 19 column names itself, in the order
readme.txt lists them. GeoNames doesn't quote its fields, so the script tells pyarrow not to look
for quotes. It reads the population as a whole number, then writes five columns to
points.parquet under the names we'll tell Mosaica to expect. Each row will be one place on the
map.
lonandlatare the place's position in degrees.name,feature_classandpopulationare what we want to see, search and filter on.
Run it.
python convert.py
wrote 29935 places to points.parquet
Every line of IE.txt is now a row in points.parquet.
Describe the data¶
Next we tell Mosaica what the data means, in a file called a declaration. It's written in TOML. A
declaration names the files to read and says how to place each row on a map. It lists the columns to
keep and the kind of value each holds, and it says who may see each row. Save this as schema.toml beside
points.parquet.
[sources]
points = "points.parquet"
[defaults]
source = "points"
[[view]]
name = "ireland"
projection = "web_mercator"
extent = { lon = [-11.0, -5.0], lat = [51.0, 55.5] }
point_visibility = { default = "public" }
[[vocabulary]]
name = "feature_class"
width = "u8"
value_set = "closed"
visibility = "public"
values = ["A", "H", "L", "P", "R", "S", "T", "U", "V"]
[[attribute]]
name = "name"
type = "text"
index = true
[[attribute]]
name = "feature_class"
type = "category"
vocabulary = "feature_class"
render = true
[[attribute]]
name = "population"
type = "i64"
render = true
The sources. [sources] gives each file a short name, with its path relative to schema.toml.
[defaults] makes points the source for every block below it, so we don't have to repeat it.
You only write these when building from files on disc. The Python client writes them for you from
the data you insert.
The view. A view is one way of laying the
places out on a flat map. It has a projection, which turns longitude and latitude into a position on
the map, and an extent, which is the part of the map it covers. A corpus can have several views. A
collection of documents might have a geographic map and a layout drawn from an embedding, for
instance, and a viewer can switch between them. We only need one, which we'll call ireland.
web_mercator is the projection almost every web map uses. It keeps shapes true, but stretches
areas more and more the further they are from the equator.
The extent gives a range of longitude and a range of latitude, in degrees. Ours is a rectangle
around Ireland. A declaration has to give an extent, but it can be "auto", which fits it to the
data. We give a rectangle so that a place with a bad coordinate can't stretch the map.
point_visibility decides who may see each place. Mosaica gives each place an access label, and
each viewer holds a set of labels. A viewer sees only the places whose label they hold. Labels
usually come from a column in your data, but we don't have one, so every place gets the default
label, public. Every viewer holds public, so everyone will see every place. The tutorial on
access control gives places different labels and shows two people two different maps.
The vocabulary. A vocabulary is the fixed list of values a category attribute can take (more on attributes below). Here it's the feature class, a single letter from a list GeoNames defines. The GeoNames feature codes page has more detail. The Ireland data has:
| Class | What GeoNames files under it | Places |
|---|---|---|
| A | counties and other administrative areas | 112 |
| H | lakes, rivers, bays and other water | 3,241 |
| L | parks, areas and localities | 3,507 |
| P | cities, towns and villages | 12,159 |
| R | roads and railways | 106 |
| S | buildings, farms and other spots | 6,722 |
| T | hills, mountains, islands and other landforms | 3,970 |
| U | undersea features | 0 |
| V | forests and heaths | 118 |
width = "u8" stores each place's class as a one-byte code, which leaves room for 255 values. For
longer lists there are u16 and u32.
value_set = "closed" says the list is complete. The build will refuse any place whose class isn't
on it. An open vocabulary is derived from the data instead, and gains a new entry whenever a new
value turns up.
visibility = "public" lets every viewer see the whole list, including U, which no Irish place
carries. The alternative, derived, would show each viewer only the values found on places they can
see. This public is a setting on the vocabulary itself, and has nothing to do with the access
label on each place.
The attributes. An attribute is a property Mosaica keeps for every place, which you can use to
draw, filter or highlight the data. We declare three. The name is text, the feature class is a
category drawn from the vocabulary above, and the population is an i64, which is a whole number.
Unlike a traditional database, Mosaica is built to stream millions of points to the user's browser for an interactive map. Most attributes aren't needed to draw the map, so when you build a database you tell Mosaica what each attribute is for. There are two settings.
render = truestores the value beside each place's position on the map, so it travels with every point that's drawn. Any value the map uses when drawing, such as the colour of each point, needs it. We set it onfeature_classandpopulation.index = truebuilds a search index for filtering the data. Onname, it lets you filter places with a text search.
An attribute with neither setting can still be read for each place, but it can't be drawn or used for filtering. It takes less memory and less disk space than one with either.
Describe the deployment¶
The declaration describes the data. A second file, mosaica.toml, configures the database itself.
It says where the bundle goes and holds the rest of the system's settings. mosaica check,
mosaica build and mosaica serve all look for mosaica.toml in the directory you run them from,
and then in the directories above, and read the declaration from schema.toml beside it. Save this
beside schema.toml.
[bundle]
path = "bundle"
cache = "cache"
wal = "wal.log"
[disclosure]
token_max_lifetime = 3600
[serve]
viewer = "127.0.0.1:9141"
session = "127.0.0.1:9142"
control = "127.0.0.1:9143"
operator_credential_file = "operator.secret"
[catalogue]
dir = "catalogue"
[bundle] names three places on disc. path is the directory the build writes and the server
opens. cache is where the server keeps work it can reuse, such as each viewer's set of places.
wal is the server's write-ahead log, where it records every change to the data before applying it,
so that a change survives a crash.
token_max_lifetime is how long a browser's permission to read the map lasts, in seconds. An hour
is plenty here.
[serve] gives the server's three addresses and the name of a file holding the operator's secret.
We'll create the file, and explain the addresses, when we start the server.
[catalogue] names the directory where Mosaica keeps its users, the passwords and keys they sign
in with, and what each may see and do. The server creates it the first time it starts.
Check the declaration¶
Before building anything, ask Mosaica what it makes of the two files. mosaica check reads the
declaration, opens each Parquet file it names, and compares the columns the declaration asks for
with the columns each file actually has. It only reads names and types, never rows, so it's quick.
mosaica check
...
identity source 'points': names items by nothing: each row is an item of its own
identity view 'ireland': names items by nothing: each row is an item of its own
projected views, from the declaration alone:
ireland web_mercator, asked for lon [-11, -5], lat [51, 55.5]
snapped outward to the square at z5 (15, 10) — lon [-11.25, 0], lat [48.922499263758255, 55.7765730186677]
views
ireland labels from default_only, default 'public'
vocabularies
feature_class public, closed, 9 declared value(s)
attributes
name text, index, from column 'name'
feature_class u8 over vocabulary 'feature_class', render, from column 'feature_class'
population i64, render, from column 'population'
check OK: 2 source(s), 1 view(s), 0 view group(s) over 0 declared view(s), 1 vocabulary(ies), 3 attribute(s), 0 layer(s), 0 warning(s)
check OK on the last line is what we want to see. Above it, the check describes what the build is
going to do.
The last line counts two sources, though we only have one file. The check reads the file's schema twice, once for the attributes and once for the view's positions.
The identity lines, one for each reading, say how the build will tell one place from another. A
database usually does that with a key. Mosaica's equivalent is an attribute declared
unique = true, such as the GeoNames id. A row carrying an id that a place already holds then
names that place and changes it. We declared no unique attribute, so nothing in our file names a
place, and each row becomes a place of its own. That's all a map built once from one file needs.
If you later wanted to correct or delete a place by its GeoNames id, you'd keep the geonameid
column in convert.py and declare it as an i64 attribute with unique = true. Mosaica would
then keep an index from each id to its place.
The projected views section holds a surprise. We asked for a rectangle, but the check says the view has been snapped outward to a square. Mosaica stores positions on a grid that lines up with the standard map tiles, so that any tile the map asks for, at any zoom, falls exactly on the grid. To make that work it widens our extent to the smallest standard tile that contains it. At zoom level 5 the world is 32 tiles across, and ours is the tile in column 15, row 10, counting from zero at the top left. In degrees, that tile runs from longitude −11.25 to 0 and from latitude 48.9 to 55.8.
The rest repeats what we declared. labels from default_only means no column supplies access
labels, so every place gets the default, public.
While nothing depends on it yet, try making a mistake. Open schema.toml, change the last
attribute's name to populaton, and run the check again.
mosaica check
...
FAILED attribute 'populaton': declared type 'i64', read from a column named 'populaton', which source 'points' does not carry. Its columns are: lon, lat, name, feature_class, population
...
check FAILED: 1 finding(s) across 2 source(s). Nothing was read but Parquet schemas, so a clean check is not a clean build: it cannot see a value against a closed vocabulary, which rows the identity rule refuses, or where the data sits inside a view's extent
The check names the attribute, says which column it went looking for, and lists the columns the file
does have. Its last line is honest about its limits. It has only seen names and types, so a class
missing from our closed vocabulary would get past it, and only the build would catch that. Change
the name back to population and run mosaica check once more to get check OK back.
Build the bundle¶
Inside the server, every place is identified by a number of Mosaica's own. That number never
leaves the server, because a viewer who collected a few of them could estimate how many places
they aren't allowed to see. Browsers get a mosaica_id for each place instead. It's the internal
number scrambled with a secret key, which the build makes up at random and keeps inside the bundle.
There is nothing for you to set up. The security
chapter explains what the scrambling
hides and what it doesn't. Each build makes a new key, so building again gives every place a new
mosaica_id, and any mosaica_id a viewer had saved will no longer point at the same place.
Now build the bundle. The build reads every row of points.parquet and works out where each place
sits on the map. It stores neighbouring places next to each other on disc, so that the places in any
map tile, at any zoom, are efficiently retrieved. Along the way it checks every feature class
against the vocabulary, builds the search index over the names and records which places carry which
access label. Everything goes into the bundle directory. From here on the server reads only the
bundle, and never looks at your Parquet file again.
mosaica build
view 'ireland': web_mercator, quantising against x [0.46875, 0.5], y [0.3125, 0.34375]
asked for lon [-11, -5], lat [51, 55.5] — snapped outward to the square at z5 (15, 10), lon [-11.25, 0], lat [48.922499263758255, 55.7765730186677]
the data spans x [0.47030425, 0.5235287777777777], y [0.3141878520203559, 0.33315262108750776] — 62277 x 39773 of the 65536 x 65536 cells
1 of 29935 point(s) (0.0%) CLAMP onto the frame's edge — 1 on x, 0 on y. A clamped point is stored at the boundary, not where it was written
none of them outside web_mercator's ±85.0511287798066° domain, so nothing was clipped
...
1 view(s): 0 point row(s) carried access terms of their own; 29935 took the declared default
...
attribute 'name': 29,935 of 29,935 entities have a value
attribute 'feature_class': 29,935 of 29,935 entities have a value
attribute 'population': 29,935 of 29,935 entities have a value
...
built /home/you/ireland/bundle (v00000): 29935 items, 1 terms, 29935 pairs, 2197486 bytes on disk, 0 artifact(s) minted, 0 unclustered member row(s), 0 row(s) refused
view ireland: 29935 row(s)
It takes a few seconds. Most of the report confirms what we expected. All 29,935 places took the default label, and every one of them has a value for each attribute. Every feature class was one of our nine letters, too. If one hadn't been, the build would have stopped and named it.
The second line gives the snapped tile in degrees, as the check did. The fourth line says one place fell outside that tile. The build has clamped it, which means it moved the place onto the nearest edge of the tile instead of dropping it.
That place is Saint Patrick's Bridge. GeoNames lists three places of that name in Ireland. One is a spit on the Wexford coast, and another is the bridge over the Lee in Cork, filed as a historic site at longitude −8.47036. The third has the spit's class and the Cork bridge's position, except that its longitude is 8.47036. The minus sign has gone missing, which puts it east of Greenwich, in Germany. The build has pinned it to the eastern edge of our map.
We'll leave it there, since it makes a handy landmark. To correct it, you'd fix the longitude in
IE.txt, run convert.py again, delete the bundle directory and build again. The build won't
write over an existing bundle. Once the server has run, delete its log as well, the wal-* files,
because the server refuses to start with a log written for another bundle.
The line beginning built names the new bundle's directory, which on your machine is the full
path of your own ireland directory; we've written it as /home/you/ireland here. It counts
29935 items, one for each row, as the check's identity lines promised, and ends with
0 row(s) refused. A build refuses a row that names a place an earlier row already named, such as
a second row carrying the same GeoNames id. It keeps the first row, leaves the later one out, prints
what it left out and writes the list to bundle/reports/refused.json. With no unique attribute,
nothing in our file can name a place twice.
Serve it¶
The server needs the secret file mosaica.toml names. Fill it with a random value that only you
can read.
openssl rand -hex 16 > operator.secret
chmod 600 operator.secret
The server listens on three addresses, each with its own job.
- The viewer address, port 9141, serves the data. Each caller gets only the places it's allowed to see.
- The session address, port 9142, gives a caller permission to read the map. It only answers callers holding a key that may act for other users, or the operator secret.
- The control address, port 9143, takes changes to the data, such as new places and deletions, and changes to the users. It only answers callers holding a key or a secret that allows the change. We'll use it once, to create the users the map's page needs.
Keeping them apart means you can open each address only to the callers that need it.
mosaica serve opens the bundle and starts answering on all three. It keeps running until you stop
it, so give it a terminal of its own.
cd ~/ireland
mosaica serve
2026-09-24T00:49:40.984688Z INFO mosaica_server::memory: the allocator's arena count is capped arenas=12
2026-09-24T00:49:41.027452Z INFO mosaica_engine::engine: the engine adopted the prefix's derived artifact structures named=0 containment_adopted=0 prefix=v00000
2026-09-24T00:49:41.027864Z INFO mosaica_server: bulk reads may hold this much memory at once, within the process's memory cap bulk_admission=2 max_page_bytes=67108864 bulk_read_memory_bytes=939524096
{"event":"listening","viewer":"127.0.0.1:9141","session":"127.0.0.1:9142","control":"127.0.0.1:9143"}
The three INFO lines are the server describing its own set-up, and you can ignore them. The last
line is the one to look for. It means all three addresses are open.
Open the map¶
The map is drawn by Mosaica's browser components, on a web page with a small Node server that
signs you in. The page and its server are an example in the Mosaica repository, so use a second
terminal to clone it. If you have already cloned it to ~/mosaica, run git pull in it instead.
git clone https://github.com/mosaicadb/mosaica ~/mosaica
Copy the page and its server into a directory of your own, and install the components beside them from npm.
mkdir ~/ireland/page && cd ~/ireland/page
cp ~/mosaica/clients/ts/examples/plain-html/index.html ~/mosaica/clients/ts/examples/plain-html/server.mjs .
npm install @mosaicajs/components
added 48 packages in 7s
The package holds the components in one file, mosaica-components.js, which the page's server
finds in node_modules and serves to the page.
Mosaica needs to know who you are signed in as. It keeps its users in the catalogue, and the page's server asks the session address for a token on your behalf.
So we need two users. everyone is the person reading the map. It holds the read permission,
which lets it read, and no access attributes of its own, since every place is labelled public and
every user holds that. page is a service account for the page's server. It holds authorise-as,
which lets it ask for a token for another user. Changes to the catalogue go to the control address, with the
operator secret.
cd ~/ireland
export MOSAICA_CREDENTIAL=$(cat operator.secret)
mosaica principal create everyone --kind person --control http://127.0.0.1:9143
mosaica grant --principal everyone --permission read --control http://127.0.0.1:9143
mosaica principal create page --kind service --control http://127.0.0.1:9143
mosaica grant --principal page --permission authorise-as --control http://127.0.0.1:9143
{"sessions_ended":0}
{"sessions_ended":0}
{"sessions_ended":0}
{"sessions_ended":0}
Each change answers how many sessions it ended. Nobody is signed in yet, so the answer is 0.
The page's server proves it is page with an API key. Mosaica shows a key once, when it makes it,
so save it straight to a file only you can read.
umask 077
mosaica key create page --control http://127.0.0.1:9143 > page.key
cat page.key
{"key":"msk_92ce602a0958_c37c39511c94d1f5dbe414cdd9dde1232c602409e2db22d01401afc0b299d46a","prefix":"92ce602a0958"}
The page's server signs you in as one of the people listed in users.json, each with the
user it reads as. Write a list with a single person, Everyone.
cd ~/ireland/page
echo '{"everyone": {"label": "Everyone", "principal": "everyone"}}' > users.json
Tell the page's server where Mosaica is listening and give it the key, then start it.
export MOSAICA_VIEWER_URL=http://127.0.0.1:9141
export MOSAICA_SESSION_URL=http://127.0.0.1:9142
export MOSAICA_API_KEY=$(python3 -c 'import json; print(json.load(open("/dev/stdin"))["key"])' < ~/ireland/page.key)
node server.mjs
plain-html example on http://localhost:5180
Open http://localhost:5180. The page signs you in as Everyone and draws every place in blue. There's no base map yet, so the outline of Ireland is drawn by the places themselves. The gap in the north-east is Northern Ireland, which GeoNames files under the United Kingdom, and the lone point far out to the east is Saint Patrick's Bridge on the edge of our tile. Scroll to zoom in, and hover over a point to see its name.
The strip along the bottom reads 29,935 shown, 29,935 matched and 29,935 visible. Visible is how many places you're allowed to see in the part of the map on screen. Matched is how many of those pass your filters, which with no filters set is all of them. Shown is how many points the page has actually drawn. It can be lower than matched, because where an area holds more matches than the page draws at once, the server sends a sample.
If you leave the page open for more than an hour, the strip will say Session expired. Reload the page to sign in again.
Filter the map¶
The panel on the left has a filter for each attribute we declared. There's a search box for the name and a range for the population. Each feature class has its own tick box, and that's where we'll start.
Tick P. The matched count drops to 12,159, the same number of cities, towns and villages as in the table.
Now type 10000 in the first Population box and press Enter. The count falls to 72, the places with ten thousand people or more. The largest of them is Dublin, with 1,024,027. A village with no population figure counts as 0, so it falls outside the range.
The visible count drops while a filter is on. With P ticked it fell to 29,687 in our window, because the page only counts visible places where it has drawn something, and some stretches of the map have no town at all. Matched is the number to watch.
Choose Clear all, type kilkenny in the Name box and press Enter. Twelve places match. There's the
county, the city and a village of the same name over in County Mayo, and then seven hotels, the
airport and the Smithwick's brewery. The search matches whole words, so the locality of Kilkennybeg
isn't among them.
Choose Clear all again, then pick feature_class under Colour by. Each class gets its own colour,
using the value that render = true sends with every point.
When you've finished, press Ctrl-C in each terminal to stop the page's server and Mosaica.
What you built¶
~/ireland now holds a working deployment. The data is in points.parquet and the declaration in
schema.toml. mosaica.toml holds the deployment's settings, operator.secret the operator's
secret, and page.key the page's key. The catalogue directory holds the two users, and the
bundle directory is what the build made from the data. To bring the map back, start
mosaica serve in ~/ireland, then run the three export lines and node server.mjs in the
example's directory. The users and the key are still in the catalogue.
What you learned¶
- A declaration tells Mosaica what your data means.
mosaica checkcompares it with the column names and types in your files, andmosaica buildreads every row. - The bundle is what the server serves. The build sorts the places into map order and writes the indexes the server needs, and after that the server never opens your source files.
- Every place carries an access label, and a viewer sees only the places whose label they hold.
Ours all carry
public, which everyone holds. - The server listens on three addresses. Browsers read the map from the viewer address, your own server gets them permission from the session address, and changes to the data and to the catalogue of users go to the control address.
- Everything a viewer is sent, every count and every point, is computed from the places their labels allow.
Next¶
The next tutorial is about access control. It gives places different labels, signs in two people who hold different ones, and compares their maps. It also explains the permission a browser gets, which Mosaica calls a token. That tutorial isn't written yet. In the meantime, the overview describes the whole system, and the data model says more about views, attributes and vocabularies. Access control covers labels and tokens.