Spatial Data Across Relational Databases

June 2, 2026

Spatial Data Across Relational Databases


Spatial data stopped being a specialist concern some years ago. Working out which customers fall inside a service area, tracking a delivery fleet, deciding where the next depot should go, showing anything at all on a map: these are ordinary application features now, and every major relational database can store coordinates and answer questions about them.

The difficulty is that each database does it differently, and spatial code is the hardest kind to move once written. SQL Server has geometry and geography types with T-SQL method syntax. PostGIS is the most complete implementation available and brings its own conventions. Oracle uses SDO_GEOMETRY and a function library that looks nothing like either. Teradata implements the OGC standard through its own dialect. SQLite has no spatial support at all until an extension is installed.

Ordinary CRUD moves between databases with moderate effort. Spatial code does not, because it is welded to vendor syntax in a way that ordinary queries are not, and because the differences reach into indexing and coordinate systems rather than staying at the level of function names. This article covers what it takes to write spatial code once and run it on every engine Velocity supports.

What can a database actually do with spatial data?

Three capabilities matter, and understanding how they build on one another explains why the differences between engines are more troublesome than they first appear.

Storage comes first. Coordinates are held in a column typed for them rather than as a pair of numbers. The database then understands that the value describes a shape and can reason about it. Points, lines and polygons are all storable, along with collections of each.

Querying is the reason for storing them that way. Once the database knows a column holds geometry it can answer questions that would otherwise require pulling every row into the application: which of these points fall inside this boundary, which are within five miles of here, how large is this region, do these two areas overlap. Those questions execute inside the engine, next to the data, and return only what matched.

Indexing is what makes the second capability usable at scale. Spatial indexes work differently from ordinary B-tree indexes, dividing space into a grid so the engine can eliminate most candidates before comparing any actual geometry. Without one, a proximity query reads every row in the table.

Every supported engine provides all three. What varies is the syntax, the type system, the indexing model, and in one case whether the capability is present at all before you install something.

Which databases support it, and what does setup involve?

All but one need no preparation beyond creating a column of the right type. SQL Server, PostgreSQL, Oracle, MySQL, DB2 and Teradata all ship spatial support in the engine, and Velocity uses whatever each provides.

SQLite is the exception, and the obstacle is larger than it first appears. Its spatial support lives in SpatiaLite, a native extension that is not part of SQLite itself, so it has to be obtained, placed somewhere the loader will find it, and activated with load_extension before any spatial work can begin. Prebuilt binaries are published for Windows and effectively nowhere else, which means every other target requires compiling SpatiaLite from source along with its dependency chain, GEOS and PROJ among them. That is a genuine porting exercise rather than a download, and it has to be repeated for each operating system and processor architecture you intend to run on, then repeated again on every build agent.

Velocity removes that step entirely. Each of the four platform builds, Windows x64, Linux x64, Linux on ARM64 and macOS on Apple silicon, ships with the matching SpatiaLite binaries and their complete dependency chain already inside the package. At runtime the correct one is selected, the platform differences in how a native library gets located are handled, the extension is loaded, and the spatial metadata tables are created when the database is created.

The practical result is that spatial code against SQLite needs no additional software and no setup step. Everything described in this article runs against a file on disk exactly as it runs against PostGIS, which makes SQLite a genuine option for local development, for automated tests, and for anything shipping to a device that will be offline.

How do you choose between Geography and Geometry?

This decision comes before any of the API matters, and it is the one most often made by accident.

Geometry treats the world as a flat plane. Coordinates are ordinary numbers on a Cartesian grid. Distances are computed with straightforward arithmetic, and results come back in whatever units the coordinates were expressed in. That model is correct for floor plans, site layouts, CAD drawings, printed circuit boards, and anything else where curvature genuinely does not apply. It is also faster, because the mathematics is simpler.

Geography treats the world as an ellipsoid. Coordinates are longitude and latitude. Distances follow the curvature of the earth, so results come back in units of length. That model is what you want for GPS coordinates, delivery routing, service areas, or anything measured in distance somebody would recognize.

using YndigoBlue.Velocity.Utils;

Geometry siteOutline = GeospatialUtils.GeometryFromText(
    "POLYGON((-100 40, -100 44, -96 44, -96 40, -100 40))");

Geography observatory = GeospatialUtils.GeographyFromText("POINT(-118.3004 34.1184)");

Both are produced by GeospatialUtils, and both compile, both store, and both return plausible numbers, which is why choosing wrongly is so easy and so quiet. Longitude and latitude held as Geometry produce distances measured in degrees, and a degree of longitude is a different real distance in Edinburgh than it is in Nairobi.

Coordinate order in that well-known text deserves attention as well. The convention is longitude first, then latitude, which reverses how people say coordinates aloud and reverses what most mapping interfaces display. POINT(-118.3004 34.1184) is Los Angeles, and transposing the two produces a point in the Indian Ocean. Because both numbers remain valid, nothing rejects the value and no error is raised, so the failure surfaces later as results that are simply wrong. This is worth a unit test in any codebase that constructs coordinates from user input.

The reference system is set once on the connection through GeographySrid rather than per query, and the default matches what almost everything uses:

var connection = new SqlServerDatasourceConnection
{
    Hostname = "localhost",
    Database = "spatialdb",
    GeographySrid = 4326
};

SRID 4326 is WGS 84, the system GPS reports in and the one GeoJSON assumes. Databases whose columns were created against something else need this set to match. That decision belongs at the start of a project, since data already written was stored under whatever the previous value was.

How do you define the column and its index?

Spatial columns are declared exactly as any other column is, with a data type that happens to be spatial, which means the schema definition carries no special handling and the same file builds the table on every engine.

Table locations = schema.CreateTable("locations");

locations.AddColumn("location_id", DataType.Long, autoGenerate: true, notNull: true);
locations.AddColumn("name", DataType.VarChar, size: 100);
locations.AddColumn("coordinates", DataType.Geography, notNull: true);

locations.CreatePrimaryKey("pk_locations", "location_id");

The index is where the engines diverge most, and where the defaults matter.

locations.CreateSpatialIndexLatLong("spi_coordinates", "coordinates");

That overload applies whole-world latitude and longitude bounds with grid sizes suited to them, which is the right starting point for data that could be anywhere on the planet. Where the data occupies a smaller area, the explicit overload takes the extent and the grid sizes directly:

locations.CreateSpatialIndex("spi_coordinates", "coordinates",
    xmin: -8.7, ymin: 49.9, xmax: 1.8, ymax: 60.9,
    smallGridSize: 0.01, mediumGridSize: 0.1, largeGridSize: 1.0);

The bounding box is worth setting deliberately rather than accepting the default, and the reason is that the engines treat it differently. PostGIS determines the extent of the data for itself and needs nothing declared. SQL Server and Teradata expect an extent, and the grid sizes govern how much of a query the index eliminates before the engine begins comparing actual geometries. Data covering one country, indexed with whole-world bounds on those engines, produces an index that exists and does very little work, which is a failure that resembles success because queries still return correct answers, only slowly.

There is also a two-argument form that leaves the extent zeroed for engines that compute it themselves:

locations.CreateSpatialIndex("spi_coordinates", "coordinates");

Whichever form is used, the index is applied through the manager:

manager.CreateSpatialIndex(locations.SpatialIndex);

Build it after loading data rather than before. Spatial indexes are maintained on every insert, so an index created ahead of a bulk load is updated once per row throughout that load.

How do you get existing data in?

Spatial data usually arrives as a file produced by something else, and well-known text in a CSV column is the most common form it takes. ImportData reads it directly, with no separate parsing step:

name,coordinates
Griffith Observatory,POINT(-118.3004 34.1184)
Central Park,POINT(-73.9654 40.7829)
Millennium Park,POINT(-87.6226 41.8827)
var config = new ImportConfig(locations);

using var manager = new Manager(connection);

manager.ImportData(locations, "locations.csv", config);

Polygons import the same way, though they need quoting in the file because the coordinate list contains commas:

region_id,region_name,boundary
1,Downtown,"POLYGON((-122.4 37.8, -122.4 37.7, -122.3 37.7, -122.3 37.8, -122.4 37.8))"
2,Airport,"POLYGON((-122.5 37.6, -122.5 37.5, -122.4 37.5, -122.4 37.6, -122.5 37.6))"

LINESTRING, MULTIPOINT, MULTILINESTRING and MULTIPOLYGON are all accepted, for either column type. Where the source file carries its own identifiers that should be preserved rather than replaced by the database's sequence, the configuration takes that as an argument:

var config = new ImportConfig(regions, keepIdentity: true);

Underneath, each engine receives the loading strategy that performs well on it, and the import goes through the database's bulk path rather than through individual inserts. None of that appears in the calling code.

How do you find what is near a point?

Proximity is the most common spatial question, and there are two ways to ask it that behave differently enough to be worth separating.

Where the distance itself is wanted, either to display or to sort by, SpatialDistance computes it:

Geography target = GeospatialUtils.GeographyFromText("POINT(-118.2437 34.0522)");

var distance = new SpatialDistance(locations["coordinates"], target, UnitOfMeasure.Kilometer);

var query = new Query()
    .Select(locations["name"])
    .Select(new Expression("distance_km", distance))
    .From(locations)
    .Where(new Criterion<double>(distance, ConditionalType.LessThanOrEqualTo, 50))
    .OrderBy(new Expression(distance));

The third argument is the part that survives a change of database. Asked for a distance without a unit, each engine answers in whatever it treats as native. On more than one of them that is degrees rather than any unit of length. Naming UnitOfMeasure.Kilometer means kilometers on every engine, with the conversion performed inside the database rather than in application code. The enumeration runs from millimeters through miles.

Where only the answer to "is it within range" is needed, SpatialDWithin returns a boolean instead:

var query = new Query()
    .SelectAll(locations)
    .Where(new Criterion<bool>(
        new SpatialDWithin(locations["coordinates"], target, 50000F), true));

The distinction is worth understanding. SpatialDistance computes an exact distance for every candidate row, whereas SpatialDWithin expresses a containment test the engine can answer using the spatial index, discarding most rows without computing anything precise. On a large table the difference is substantial. Use the distance function when the number matters, and the containment test when only membership does.

How do you find what falls inside an area?

Containment questions take a shape rather than a point, and the shape can be a literal or another column.

Testing against a polygon defined in the query:

Geography searchArea = GeospatialUtils.GeographyFromText(
    "POLYGON((-118.5 33.7, -118.5 34.3, -117.9 34.3, -117.9 33.7, -118.5 33.7))");

var query = new Query()
    .Select([ locations["name"], locations["coordinates"] ])
    .From(locations)
    .Where(new Criterion<bool>(
        new SpatialContains(searchArea, locations["coordinates"]), true));

Testing one table against another, which is the more useful form once boundaries are stored rather than written into queries:

var query = new Query()
    .SelectAll(locations)
    .Where([
        new Criterion<bool>(
            new SpatialContains(regions["boundary"], locations["coordinates"]), true),
        new BooleanItem(BooleanType.And),
        new Criterion<bool>(locations["is_active"], true)
    ]);

Spatial predicates compose with ordinary criteria exactly as any other condition would, which is the practical benefit of holding the geometry in the database rather than filtering in application code. The engine applies both conditions together and returns only the rows that satisfy both.

The full set of OGC relationship predicates is available, and each expresses a different relationship precisely: SpatialContains, SpatialWithin, SpatialIntersects, SpatialOverlaps, SpatialTouches, SpatialCrosses, SpatialDisjoint, SpatialCovers and SpatialEquals. The distinctions between them matter for correctness. Two regions that share a border touch but do not overlap, and a point exactly on a boundary is contained by some definitions and not others.

How do you measure and transform shapes?

The engines can also compute properties of the shapes themselves, and those computations are available as ordinary select items.

SpatialArea, in units you choose:

var query = new Query()
    .Select(regions["region_name"])
    .Select(new Expression("area_sq_mi",
        new SpatialArea(regions["boundary"], UnitOfMeasure.Mile)))
    .From(regions);

SpatialLength, for linear features such as routes or pipelines:

var query = new Query()
    .Select(routes["route_name"])
    .Select(new Expression("length_km",
        new SpatialLength(routes["path"], UnitOfMeasure.Kilometer)))
    .From(routes);

SpatialBuffer, which produces a new shape rather than a number, describing everything within a given distance of the original:

var query = new Query()
    .Select(facilities["name"])
    .Select(new Expression("service_area",
        new SpatialBuffer(facilities["location"], 1000F, UnitOfMeasure.Meter)))
    .From(facilities);

Buffers are how service areas, exclusion zones and catchment boundaries are usually derived. The result is itself a geometry, so it can be fed into further predicates within the same query rather than round-tripped through the application.

The wider function set covers centroids, envelopes, convex and concave hulls, unions, intersections and differences, along with inspection functions that report a geometry's type, dimension, point count and validity. There are more than forty in total, all constructed the same way, so the shape of one is the shape of all of them.

How do you combine one shape with another?

Beyond measuring individual shapes, the engines can derive new geometry from two existing ones, which is how overlaps, coverage gaps and merged territories are calculated without moving any data into the application.

SpatialIntersection produces the shape common to both inputs, which answers questions about overlap directly rather than by inference:

Geography floodZone = GeospatialUtils.GeographyFromText(
    "POLYGON((-122.45 37.75, -122.45 37.80, -122.38 37.80, -122.38 37.75, -122.45 37.75))");

var query = new Query()
    .Select(parcels["parcel_id"])
    .Select(new Expression("affected_area",
        new SpatialArea(new SpatialIntersection(parcels["boundary"], floodZone), UnitOfMeasure.Meter)))
    .From(parcels);

Note what is happening in that expression. SpatialIntersection produces a geometry, and SpatialArea accepts a function as readily as a column. The two therefore compose into a single calculation the database performs in one pass. Most of the spatial functions take a Function overload for exactly this reason, which means derived geometry rarely needs a round trip through your code.

SpatialUnion merges two shapes into one, which is how adjoining territories are consolidated:

var query = new Query()
    .Select(new Expression("combined",
        new SpatialUnion(regions["boundary"], adjacentArea)))
    .From(regions);

SpatialDifference removes one shape from another, giving the part of the first that the second does not cover:

var query = new Query()
    .Select(regions["region_name"])
    .Select(new Expression("uncovered",
        new SpatialDifference(regions["boundary"], serviceArea)))
    .From(regions);

Three derived shapes are worth knowing alongside these because they answer common questions cheaply. SpatialCentroid returns the center of mass of a shape, which is what you want for placing a label on a map. SpatialEnvelope returns the smallest axis-aligned rectangle containing it, which is a fast approximation for coarse filtering. SpatialConvexHull returns the tightest convex boundary around a shape or a set of points, which is how a territory is derived from the locations inside it.

var query = new Query()
    .Select(regions["region_name"])
    .Select(new Expression("label_point", new SpatialCentroid(regions["boundary"])))
    .Select(new Expression("bounds", new SpatialEnvelope(regions["boundary"])))
    .From(regions);

How do you check the shapes are valid?

Geometry arriving from an external source is frequently imperfect, and the failures are of a kind that produce wrong answers rather than errors. Polygons whose edges cross themselves, rings that do not close, and shapes with duplicate consecutive points are all common in files exported by older tools or assembled by hand, and every one of them will store without complaint.

Validity is testable in the database, so a whole table can be checked in one query:

var invalid = new Query()
    .Select(regions["region_id"])
    .Select(new Expression("kind", new SpatialGeometryType(regions["boundary"])))
    .Select(new Expression("points", new SpatialNumPoints(regions["boundary"])))
    .From(regions)
    .Where(new Criterion<bool>(new SpatialIsValid(regions["boundary"]), false));

SpatialIsValid reports whether a shape satisfies the rules for its type, which for polygons means closed rings that do not cross themselves. SpatialIsSimple is the related test for linear features, reporting whether a line crosses itself. SpatialIsEmpty distinguishes a geometry that exists but contains nothing from one that is absent altogether, which matters because an intersection of two shapes that do not overlap returns an empty geometry rather than null.

The inspection functions alongside them are useful during an import rather than in production queries. SpatialGeometryType reports what a value actually is, which catches a file that turned out to contain a mixture of points and polygons when it was supposed to hold only one. SpatialNumPoints reports vertex counts, which is how an unexpectedly slow query is traced to a handful of boundaries carrying tens of thousands of vertices each.

One engine-specific behaviour applies here. DB2 rejects self-intersecting polygons at insert time rather than storing them for later testing. A file that imports cleanly elsewhere may therefore fail there. Checking validity before loading, rather than after, is the portable habit.

How do you read spatial values back?

Spatial columns come out of a result set as spatial values rather than as strings, which means they can be used directly rather than parsed:

using ResultSet results = manager.Retrieve(query);

foreach (Result record in results)
{
    Geography zone = record.GetFieldGeography("service_area");
    string name = record.GetFieldVarChar("name");
}

GetFieldGeometry is the planar counterpart. Both return values that can be passed straight back into another query as literals, which is what makes multi-step spatial work practical: compute a buffer, read it, then use it as the search area for a second question without ever converting to text and back.

Binding works as it does for any other column, so a class with a Geography property is populated by the same BindResults call used everywhere else:

class Facility
{
    public long FacilityId { get; set; }
    public string Name { get; set; }
    public Geography Location { get; set; }
}

IList<Facility> facilities = manager.Retrieve<Facility>(query, BindingType.SnakeCase);

How do you write spatial values?

Inserting and updating spatial columns follows the same pattern as every other type. A spatial table therefore needs no special repository and no separate write path.

Setting a value directly on a record uses the typed setter:

Record record = new Record(locations);

record.SetFieldVarChar("name", "Griffith Observatory");
record.SetFieldGeography("coordinates",
    GeospatialUtils.GeographyFromText("POINT(-118.3004 34.1184)"));

long locationId = manager.AddRecord(record);

SetFieldGeometry is the planar counterpart, and there is an overload of SetFieldGeography accepting a Geometry for the case where a value produced by a geometry operation is being stored into a geography column.

Binding works as it does elsewhere, so a class carrying a Geography property is written by the same call used for anything else:

class Facility
{
    public long FacilityId { get; set; }
    public string Name { get; set; }
    public bool IsActive { get; set; }
    public Geography Location { get; set; }
}

var facility = new Facility
{
    Name = "North Depot",
    IsActive = true,
    Location = GeospatialUtils.GeographyFromText("POINT(-2.2426 53.4808)")
};

Record<Facility> record = new Record<Facility>(facilities, BindingType.SnakeCase);

long facilityId = manager.AddRecord(record.Bind(facility));

Updates take the same setters:

var update = new Update(facilities,
    new Criterion<long>(facilities["facility_id"], facilityId));

update.SetFieldGeography("location",
    GeospatialUtils.GeographyFromText("POINT(-2.2440 53.4790)"));

manager.UpdateRecords(update);

Because a spatial value read from a query is the same type as one constructed from text, the output of one operation can be written straight back without conversion. Computing a buffer, reading it with GetFieldGeography, and storing it into a service_area column is three steps with nothing in between them, which is what makes derived geometry practical to persist rather than recalculate on every request.

How do you get it out to a map?

Results export to GeoJSON through GetGeoJSON, which is what Leaflet, Mapbox GL, OpenLayers and QGIS all read without assistance. Two arguments do the work, and only the first is required:

var query = new Query()
    .Select([ locations["location_id"], locations["name"], locations["coordinates"] ])
    .From(locations);

using ResultSet results = manager.Retrieve(query);

string geoJson = results.GetGeoJSON("coordinates", "location_id");

Naming the geometry column is the first argument. The second names the column promoted to each feature's top-level id, which produces this:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "id": 1,
      "geometry": { "type": "Point", "coordinates": [-118.3004, 34.1184] },
      "properties": { "name": "Griffith Observatory" }
    }
  ]
}

The second argument determines how usable the output is in a browser. GeoJSON reserves a top-level id for exactly this purpose, and mapping libraries use it to key features for hover state, selection, and joins against data the browser already holds. Filling it with your real primary key means the browser refers to a feature by the same identifier the database does. Omitting it forces every interaction to reach into properties instead.

Every other selected column becomes a property under its own name, so the query determines the shape of the file. The argument defaults to "id", so a table whose key column is called id needs nothing said about it. Passing an empty string disables promotion entirely.

For larger exports the streaming form writes each feature as it arrives from the reader, holding one row at a time rather than assembling the document in memory:

using ResultReader reader = manager.Search(query);
using FileStream file = File.Create("locations.geojson");

reader.GetGeoJSON(file, "coordinates", "location_id");

Which makes serving a map layer from an API endpoint about as short as it can reasonably be:

[HttpGet("/export/locations.geojson")]
public async Task ExportLocations(CancellationToken ct)
{
    Response.ContentType = "application/geo+json";

    using var manager = new Manager(_connection);
    await using ResultReader reader = await manager.SearchAsync(query, ct);

    await reader.GetGeoJSONAsync(Response.Body, "coordinates", "location_id", ct);
}

One constraint applies to any GeoJSON export. The format assumes WGS 84 longitude and latitude, with no field in which to record anything else. Coordinates are therefore written as stored, without reprojection. Columns held in some other reference system will produce a file whose numbers are correct but whose contents are not standards conformant, and anything reading it will place the data somewhere it is not.

GeoJSON travels in the other direction as well, which matters because the format is how spatial data usually arrives rather than only how it leaves. ImportGeoJSON reads a file into a table, mapping the geometry, the feature identifier and any properties onto columns, and it streams for files larger than available memory. The mapping has more to it than an export does, since a feature nests where a row does not and the identifier sits outside the properties, so it is covered separately in Importing and Exporting GeoJSON.

Do all the engines give the same answer?

A unified API does not make the engines identical underneath, and it would be misleading to imply otherwise. Each vendor uses its own ellipsoid model and its own geodesic algorithms, so the same distance calculation returns slightly different numbers depending on where it runs.

Los Angeles to New York, as computed by each:

Database Result
SQL Server 3,944.42 km
PostgreSQL 3,944.41 km
Oracle 3,944.43 km
Teradata 3,944.40 km

Thirty meters across four thousand kilometers, which is irrelevant for almost any application and important to know before writing an equality assertion in a test that runs against one database and deploys against another. Short distances vary by well under one hundredth of a percent. Area calculations can reach one percent, and buffers can differ visibly near the poles where the ellipsoid models disagree most.

The practical guidance is to build tolerance into spatial comparisons rather than asserting exact equality, and to perform any arithmetic whose precision genuinely matters on the platform you actually ship on.

A small number of engine-specific behaviours are worth knowing about as well. DB2 rejects self-intersecting polygons outright, whereas other engines accept them and let you test validity afterwards. DB2 also orders a polygon's interior rings by its own algorithm rather than the order given, which makes interior ring access non-deterministic with respect to that order. Oracle 19c cannot create spatial indexes on identifiers written in escaped quote style, which is governed by a context setting. All three are documented rather than hidden, because a stated difference is far easier to work with than one discovered in production.

Putting it together

The complete sequence, from an empty schema to a map layer, is short enough to read in one piece.

Defining the table and its index:

using var manager = new Manager(connection);

Schema schema = manager.LoadSchema("app");
Table locations = schema.CreateTable("locations");

locations.AddColumn("location_id", DataType.Long, autoGenerate: true, notNull: true);
locations.AddColumn("name", DataType.VarChar, size: 100);
locations.AddColumn("is_active", DataType.Boolean);
locations.AddColumn("coordinates", DataType.Geography, notNull: true);
locations.CreatePrimaryKey("pk_locations", "location_id");

manager.BuildSchema(schema);

Loading the data, then indexing it:

manager.ImportData(locations, "locations.csv", new ImportConfig(locations));

locations.CreateSpatialIndexLatLong("spi_coordinates", "coordinates");
manager.CreateSpatialIndex(locations.SpatialIndex);

Asking a question of it:

Geography target = GeospatialUtils.GeographyFromText("POINT(-118.2437 34.0522)");

var distance = new SpatialDistance(locations["coordinates"], target, UnitOfMeasure.Mile);

var query = new Query()
    .Select([
        locations["location_id"],
        locations["name"],
        new Expression("miles", distance),
        locations["coordinates"]
    ])
    .From(locations)
    .Where([
        new Criterion<double>(distance, ConditionalType.LessThanOrEqualTo, 25),
        new BooleanItem(BooleanType.And),
        new Criterion<bool>(locations["is_active"], true)
    ])
    .OrderBy(new Expression(distance));

And returning the answer to a browser:

using ResultReader reader = manager.Search(query);
using FileStream file = File.Create("nearby.geojson");

reader.GetGeoJSON(file, "coordinates", "location_id");

Every call above is identical on every supported engine. The only line aware of which engine is in use is the connection object at the top, and twenty-five miles means twenty-five miles on all of them.

That is the entire argument. Spatial code has historically been the part of an application most firmly attached to one vendor, because the type systems, the function names, the indexing models and the unit conventions all differ, and because the differences are subtle enough to produce wrong answers rather than errors. None of that has to reach the code you write.

Where to start

The free Community Edition is published on nuget.org and covers MySQL and PostgreSQL for ordinary data access, which is enough to become familiar with the query and schema API:

dotnet add package YndigoBlue.Velocity.Community

Spatial functionality forms part of the full edition, alongside the other five engines.

For a first test, SQLite is the fastest route, precisely because it needs no server and no extension installed. Create a file, define a table with a Geography column, import a handful of points from a CSV, and run a distance query against them. The same code then runs unchanged against whichever engine your application actually targets.

* SpatiaLite is released under a tri-license of MPL 1.1, GPL v2.0+ or LGPL v2.1+. Velocity ships it under the MPL 1.1 terms. The binaries are unmodified upstream builds, loaded dynamically at runtime rather than statically linked, and the LGPL-licensed dependencies it relies on, GEOS and libiconv, travel as separate shared libraries you are free to replace with your own builds. No GPL or LGPL obligation reaches Velocity or anything written against it. The full component list and license terms for your platform ship in the license file inside each package.