Wiener Haltestellen aus data.gv.at nach PostgreSQL/PostGIS importieren – Schritt für Schritt (Ubuntu, macOS & Windows/WSL)

Weltkarte zweimal: oben in EPSG:4326 (Grad als x/y), unten in EPSG:3857 (Web Mercator). Gleich große Kreise werden bei Mercator Richtung Pol immer größer; Wien ist markiert.
Schema der Zylinderprojektion: Ein Zylinder berührt die Erdkugel am Äquator, die Erdoberfläche wird darauf abgebildet und zur Mercator-Weltkarte abgerollt; die Breitenkreise rücken zu den Polen hin auseinander.
Von der Kugel zur Karte: Zylinderprojektion und Mercator-Karte. Grafik: © 2010 Encyclopædia Britannica, Inc.

Wo ist die nächste Haltestelle? Welche Linien fahren im Umkreis von 500 m um die Schule? Die Stadt Wien veröffentlicht alle Haltepunkte des öffentlichen Verkehrs als Open Data auf data.gv.at. Wir holen diese Daten nach PostgreSQL mit PostGIS, stellen räumliche Abfragen, geocodieren Adressen und bauen daraus eine Karte in QGIS.

Alles Schritt für Schritt für Ubuntu, macOS und Windows (WSL 2), mit einem ✅ Test nach jedem Schritt. Davor klären wir, was Koordinaten eigentlich bedeuten: 16.357 48.186, 1820867 6137793 und 1858 338579 sind derselbe Punkt – die Spengergasse – in drei Koordinatensystemen. Wer das verwechselt, misst 50 % zu viel oder landet im Jemen.

Inhalt


Was wir bauen

data.gv.at  →  „Öffentliches Verkehrsnetz Haltestellen Wien“ (WFS, Layer OEFFHALTESTOGD)
    │  curl  (CSV, Koordinaten in EPSG:4326)
    ▼
haltestellen.csv           10.428 Haltepunkte, z. B. "POINT (16.3843 48.1835)"
    │  psql \copy           (als Rolle gisuser)
    ▼
haltestellen_import        Staging-Tabelle, alles text
    │  INSERT … SELECT ST_GeomFromText(shape, 4326)
    ▼
haltestellen               geometry(Point, 4326) + GiST-Index  (Datenbank hs)
    │
    ├─ SQL: nächste Haltestellen (<->), Umkreis (ST_DWithin), Linien (text[] @>)
    ├─ adressen  ←  Nominatim (Geocodierung)
    └─ Views v_stationen, v_adressen_umkreis, v_naechste_ubahn  →  QGIS
KomponenteWerkzeug
DatenbankPostgreSQL 18 (für 17: Versionsnummer in den Paketnamen ersetzen)
Geo-ErweiterungPostGIS 3.6
Rolle / Datenbankgisuser / hs
DatenÖffentliches Verkehrsnetz Haltestellen Wien (Stadt Wien, CC BY 4.0)
Werkzeugecurl, jq, psql, QGIS

Koordinaten sind nicht gleich Koordinaten: WGS 84, Mercator und EPSG

Eine Koordinate ist nur zusammen mit ihrem Koordinatenreferenzsystem (CRS) eindeutig. Die Systeme sind im EPSG-Register nummeriert; PostGIS nennt die Nummer SRID und kennt über 8.000 davon (Tabelle spatial_ref_sys).

EPSG:4326 – WGS 84: die Erde in Grad

EPSG:4326 ist das System von GPS: geografische Länge (−180° bis 180°) und Breite (−90° bis 90°) auf dem Ellipsoid WGS 84. Das ist keine Kartenprojektion, sondern eine Position auf einer gekrümmten Fläche. Unsere Daten kommen so: POINT (16.3843 48.1835). Zwei Fallen:

  • Grad sind keine Meter. Ein Breitengrad ist überall ca. 111 km, ein Längengrad in Wien nur 111 km × cos(48,2°) ≈ 74 km. Eine mit Pythagoras in 4326 berechnete „Entfernung“ ist eine Zahl in Grad und als Entfernung wertlos. Abhilfe: der PostGIS-Typ geography, der auf dem Ellipsoid in Metern rechnet.
  • Die Reihenfolge. EPSG und Google Maps schreiben Breite, Länge (48.18563, 16.35713); PostGIS, GeoJSON, KML und WKT schreiben x, y = Länge, Breite: ST_MakePoint(16.35713, 48.18563). Vertauscht landet die Spengergasse im Jemen, 4.568 km entfernt (Schritt 8).

Das Problem der Projektion: Mercator

Bildschirm und Papier sind flach, die Erde nicht. Jede Kartenprojektion rechnet Länge/Breite in ebene x/y um, und keine schafft das ohne Verzerrung. Man wählt, was erhalten bleibt: Winkel (winkeltreu), Flächen (flächentreu) oder Entfernungen entlang bestimmter Linien.

Mercator (1569) ist eine winkeltreue Zylinderprojektion: Ein Zylinder berührt die Erde am Äquator, die Längengrade werden zu senkrechten Linien mit gleichem Abstand. Dadurch wird alles in Ost-West-Richtung um den Faktor 1/cos(Breite) gedehnt. Damit Winkel stimmen, wird Nord-Süd um denselben Faktor gestreckt; die Breitenkreise rücken zu den Polen hin auseinander.

x = R · λ
y = R · ln( tan(π/4 + φ/2) )          λ = Länge, φ = Breite (Bogenmaß)

Maßstabsfaktor k = 1 / cos φ          Äquator: 1,00   Wien (48,2°): 1,50   Oslo (60°): 2,00   Pol: ∞

Die Grafik oben zeigt daneben die einfache Zylinderprojektion mit einer Lichtquelle im Erdmittelpunkt. Die ist nicht winkeltreu; Mercator ist rein mathematisch so konstruiert, dass ein kleiner Kreis auf der Erde auf der Karte ein Kreis bleibt, nur größer:

Weltkarte zweimal: oben in EPSG:4326 (Grad als x/y), unten in EPSG:3857 (Web Mercator). Gleich große Kreise werden bei Mercator Richtung Pol immer größer; Wien ist markiert.
Dieselben roten Kreise (je 700 km Radius) – oben in EPSG:4326, unten in Web Mercator. Mercator hält die Form, bläht zu den Polen hin auf. Kartendaten: Natural Earth (gemeinfrei).

Oben werden die Kreise im Norden zu flachen Ellipsen, unten bleiben sie Kreise, werden aber riesig: Grönland erscheint so groß wie Afrika, das 14-mal größer ist. In Wien ist jede Strecke auf einer Mercator-Karte 1,5-mal, jede Fläche 2,25-mal zu groß.

EPSG:3857 – Web Mercator, das System der Online-Karten

Google hat 2005 für Google Maps eine vereinfachte Variante gewählt: WGS-84-Koordinaten mit den Kugelformeln projiziert (Radius 6.378.137 m). Geodätisch ist das falsch – die EPSG nahm das System zunächst nicht auf, es lief unter der inoffiziellen Nummer 900913 („google“ in Leetspeak). Heute heißt es EPSG:3857 „WGS 84 / Pseudo-Mercator“; gegenüber dem ellipsoidischen Mercator (EPSG:3395) liegt Wien darin 32 km weiter nördlich. Trotzdem verwenden es praktisch alle Online-Karten (Google Maps, OpenStreetMap, Bing, Apple, basemap.at):

  • Winkeltreu: Kreuzungen bleiben rechtwinkelig, Gebäude behalten ihre Form, Norden ist oben.
  • Quadratische Welt: Bei ±85,05° abgeschnitten ist die Weltkarte ein Quadrat. Zoomstufe 0 ist eine Kachel mit 256×256 Pixel, jede weitere Stufe teilt jede Kachel in vier; jede Kachel hat eine Adresse z/x/y.
  • Die Einheit heißt „Meter“, stimmt aber nur am Äquator. In Wien ist ein 3857-Meter 0,67 m lang. Zum Messen ist 3857 ungeeignet (Schritt 8).

OpenStreetMap, Google Maps, Google Earth – welches System, und warum nicht austauschbar?

Daten gespeichert inAnzeigeLizenz
OpenStreetMapWGS 84 (EPSG:4326), 7 Nachkommastellen (≈ 1 cm)Kacheln in EPSG:3857ODbL: frei nutzbar mit Namensnennung, abgeleitete Datenbanken wieder offen
Google MapsWGS 84 (LatLng, Breite zuerst)Kacheln in EPSG:3857Proprietär: Daten und Kacheln nur über Google-APIs
Google EarthWGS 84; KML ist immer EPSG:4326 (Länge, Breite, Höhe)3D-Globus, keine KartenprojektionProprietär

OSM und Google Maps passen auf den ersten Blick zusammen (4326 rein, 3857 raus). Austauschbar sind sie trotzdem nicht:

  • Daten ≠ Darstellung. 4326 beschreibt eine Position auf dem Ellipsoid, 3857 eine Bildschirmkarte. Rohdaten ohne Umrechnung auf eine Karte zu legen verschiebt oder verzerrt sie. Google Earth hat gar keine flache Karte; eine Earth-Ansicht wird nie zur Kachel.
  • Unterschiedliche Geometrie. OSM-Straßen wurden von Freiwilligen digitalisiert, Googles aus anderen Quellen. Beide in WGS 84, aber oft einige Meter nebeneinander.
  • Lizenzen. OSM-Daten darf man herunterladen, speichern und weiterverarbeiten. Google-Daten und -Kacheln darf man nicht außerhalb der Google-APIs speichern oder mit fremden Karten kombinieren.

Österreich: MGI, Gauß-Krüger – und warum NÖ Atlas und DORIS eigene Systeme haben

Der NÖ Atlas rechnet intern mit EPSG:31259 (calcCrs=31259 im Quelltext), die Kartenlinks von DORIS (Oberösterreich) tragen srs=31255, die Stadt Wien gibt ihre Bounding Boxes in EPSG:31256 an. Der Grund:

  • Kataster: Die österreichische Vermessung beruht auf dem Datum MGI (Militärgeographisches Institut; Bessel-Ellipsoid 1841, Fundamentalpunkt Hermannskogel). Flächenwidmung, Leitungskataster, Straßenbau liegen in MGI vor und müssen zentimetergenau zum Kataster passen.
  • Anderes Datum, andere Zahlen: MGI und WGS 84 verwenden verschiedene, gegeneinander verschobene Ellipsoide. Dieselben Zahlen 16.35713 48.18563 bezeichnen in MGI einen Punkt 105 m neben dem WGS-84-Punkt (Schritt 8).
  • Gauß-Krüger statt Mercator: Der Zylinder liegt quer und berührt die Erde entlang eines Meridians (transversale Mercator-Projektion). Österreich ist in drei Streifen geteilt, benannt nach der Länge östlich von Ferro: M28 (Vorarlberg, Tirol), M31 (Salzburg, Oberösterreich, Kärnten), M34 (Niederösterreich, Wien, Burgenland). Nahe am Mittelmeridian ist die Verzerrung vernachlässigbar, die Koordinaten sind Meter.
  • Deshalb hat jedes Land sein System: Oberösterreich liegt in M31 → DORIS verwendet 31255 (GK Central). Niederösterreich und Wien liegen in M34 → NÖ Atlas 31259 (GK M34, mit 750 km Rechtswert-Offset), Wien 31256 (GK East, ohne Offset). Für ganz Österreich gibt es 31287 (Austria Lambert).
EPSGNameEinheitWofür
4326WGS 84GradGPS, Datenaustausch, GeoJSON/KML, unsere Speicherung
3857WGS 84 / Pseudo-Mercator„Meter“ (nur am Äquator echt)Anzeige: OSM, Google Maps, basemap.at
4312MGIGradGeografische Koordinaten im österreichischen Landessystem
31254 / 31255 / 31256MGI / Austria GK West / Central / EastmGauß-Krüger M28 / M31 / M34 – Wien: 31256, DORIS: 31255
31257 / 31258 / 31259MGI / Austria GK M28 / M31 / M34mwie oben, mit Offset 150/450/750 km – NÖ Atlas: 31259
31287MGI / Austria LambertmGanz Österreich auf einer Karte

Faustregel: Speichern in 4326, messen mit geography oder in einem metrischen Landessystem (31256 für Wien), anzeigen in 3857. Umrechnen kann PostGIS mit ST_Transform; QGIS tut es beim Anzeigen automatisch.


Die Daten: „Öffentliches Verkehrsnetz Haltestellen Wien“

Der Datensatz Öffentliches Verkehrsnetz Haltestellen Wien enthält alle Haltepunkte von Straßenbahn, Bus, U-Bahn, S-Bahn, Badner Bahn und Nachtbussen in Wien und Umland. data.gv.at ist der Katalog; die Daten liefert der Geodienst der Stadt Wien als WFS (Web Feature Service). srsName=EPSG:4326 lässt den Server in WGS 84 umrechnen, outputFormat=csv liefert CSV (alternativ json oder shape-zip). Lizenz CC BY 4.0, Namensnennung „Datenquelle: Stadt Wien – data.wien.gv.at“.

SpalteBeispielBedeutung
OBJECTID16995666Eindeutige Nummer des Haltepunkts
SHAPEPOINT (16.3843 48.1835)Geometrie als WKT (Well-Known Text), Länge vor Breite, EPSG:4326
HTXT / HTXTKArsenalName (lang/kurz)
HLINIEN12A, 14ALinien, kommagetrennt
LTYP2Verkehrsmittel (siehe unten)
WEBLINK1https://anachb.vor.at?…Fahrplanauskunft

Eine offizielle Legende für LTYP gibt es im Datensatz nicht; aus den Linien ergibt sich:

LTYPVerkehrsmittelBeispiel-Linien
1Straßenbahn1, 2, 62, D
2Autobus (Wiener Linien)12A, 14A, 59A
3Regionalbus150, 272, 540
4U-BahnU1–U6
5S-Bahn / RegionalzugS45, REX6
9Badner BahnWLB
10–13NachtbusN25, N62

Eine Haltestelle wie „Laurenzgasse“ besteht aus mehreren Haltepunkten (je Richtung, je Verkehrsmittel): 10.428 Zeilen verteilen sich auf 3.647 Namen.


Schritt 1: PostgreSQL installieren

PostgreSQL 18 aus den offiziellen Quellen von postgresql.org. Wer schon PostgreSQL 17 hat, ersetzt im Folgenden 18 durch 17.

Ubuntu

Über das offizielle PostgreSQL-APT-Repository (PGDG) – nur dort gibt es PostGIS für die aktuelle PostgreSQL-Version (Anleitung):

sudo apt update
sudo apt install -y postgresql-common curl
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh   # richtet apt.postgresql.org ein
sudo apt install -y postgresql-18

✅ Test:

pg_lsclusters
sudo -u postgres psql -c "SELECT version();"

Erwartet: 18 main 5432 online … und PostgreSQL 18.x on x86_64-pc-linux-gnu …. Bei einer anderen Version kommt das Paket aus dem Ubuntu-Repository: prüfen, ob /etc/apt/sources.list.d/pgdg.sources existiert.

macOS

Mit Homebrew (brew.sh); das PostGIS-Paket passt dort zu PostgreSQL 17 und 18.

brew install postgresql@18
brew services start postgresql@18

# postgresql@18 ist "keg-only" → Pfad setzen
echo 'export PATH="$(brew --prefix)/opt/postgresql@18/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

Bei Homebrew heißt der Superuser nicht postgres, sondern wie dein macOS-Benutzer.

✅ Test:

brew services list | grep postgresql
which psql
psql postgres -c "SELECT version();"

Erwartet: postgresql@18 started, …/opt/postgresql@18/bin/psql und PostgreSQL 18.x on aarch64-apple-darwin …. Zeigt which psql einen anderen Pfad, wird ein falscher Client verwendet → PATH prüfen.


Schritt 2: PostGIS installieren

PostGIS ergänzt PostgreSQL um die Typen geometry (rechnet in der Ebene, in den Einheiten des CRS) und geography (rechnet auf dem Ellipsoid, in Metern), räumliche Indizes und Funktionen wie ST_Distance, ST_DWithin, ST_Transform.

Ubuntu

sudo apt install -y postgresql-18-postgis-3

✅ Test:

sudo -u postgres psql -c "SELECT name, default_version FROM pg_available_extensions WHERE name LIKE 'postgis%';"

Erwartet: eine Zeile postgis | 3.6.x. Ist die Liste leer, wurde PostGIS für eine andere PostgreSQL-Version installiert.

macOS

brew install postgis
brew services restart postgresql@18

✅ Test:

psql postgres -c "SELECT name, default_version FROM pg_available_extensions WHERE name LIKE 'postgis%';"

Erwartet: eine Zeile postgis | 3.6.x.


Schritt 3: Rolle gisuser und Datenbank hs anlegen

Wir arbeiten nicht als Superuser, sondern als Login-Rolle gisuser, Eigentümerin der Datenbank hs. Nur die Extension muss einmalig ein Superuser aktivieren.

# Ubuntu
sudo -u postgres psql

# macOS (Homebrew)
psql postgres

In der psql-Shell (Passwort anpassen):

CREATE ROLE gisuser WITH LOGIN PASSWORD 'gis123';
CREATE DATABASE hs OWNER gisuser;

\c hs
CREATE EXTENSION IF NOT EXISTS postgis;
\dx
\q

Seit PostgreSQL 15 gehört das Schema public dem Datenbank-Eigentümer; gisuser braucht keine weiteren GRANTs.

✅ Test:

# 1) Login als gisuser über TCP (so verbinden sich auch die Skripte)
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT current_user, current_database(), postgis_version();"

# 2) Luftlinie Spengergasse → Stephansplatz in Metern
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT round(ST_Distance(
    ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326)::geography,
    ST_SetSRID(ST_MakePoint(16.37208, 48.20849), 4326)::geography)) AS meter;"

# 3) Darf gisuser Tabellen mit Geometrie anlegen?
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "CREATE TABLE t_test (p geometry(Point, 4326)); DROP TABLE t_test;"

Erwartet: gisuser | hs | 3.6 USE_GEOS=1 USE_PROJ=1 USE_STATS=1, 2774 m und CREATE TABLE / DROP TABLE ohne Fehler. Peer authentication failed heißt: ohne localhost verbunden – die URL wie angegeben verwenden.


Schritt 4: Daten von data.gv.at herunterladen

Die URL steht auf der Datensatz-Seite bei der Ressource „CSV“. Die Anführungszeichen sind nötig, sonst deutet die Shell & als „im Hintergrund ausführen“.

mkdir -p ~/haltestellen
cd ~/haltestellen
curl -fsSL -o haltestellen.csv "https://data.wien.gv.at/daten/geo?service=WFS&request=GetFeature&version=1.1.0&typeName=ogdwien:OEFFHALTESTOGD&srsName=EPSG:4326&outputFormat=csv"

Ist data.wien.gv.at nicht erreichbar: Kopie vom 5. Oktober 2026 auf diesem Blog – haltestellen.csv (2 MB; Datenquelle: Stadt Wien – data.wien.gv.at, CC BY 4.0). Als ~/haltestellen/haltestellen.csv speichern.

✅ Test:

wc -l haltestellen.csv
head -3 haltestellen.csv
file haltestellen.csv

Erwartet: ca. 10429 Zeilen (die Zahl ändert sich mit jeder Aktualisierung), Kopfzeile FID,OBJECTID,SHAPE,HTXT,HTXTK,HLINIEN,DIVA_ID,LTYP,WEBLINK1,SE_ANNO_CAD_DATA, dann Zeilen wie …,POINT (16.3843… 48.1835…),Arsenal,Arsenal,69A,,2,…; file meldet CSV text. Steht XML mit ServiceException in der Datei, ist die URL falsch.


Schritt 5: Import in eine Staging-Tabelle

Zuerst 1:1 in eine Tabelle, deren Spalten alle text sind; Typumwandlung und Geometrie folgen in SQL, wo sich Fehler gezielt finden lassen. \copy (mit Backslash) ist ein psql-Befehl: Die Datei wird vom Client gelesen, braucht keine Superuser-Rechte und muss nicht auf dem Server liegen. Der Pfad ist relativ zum Startverzeichnis von psql.

Datei 01_staging.sql in ~/haltestellen:

DROP TABLE IF EXISTS haltestellen_import;
CREATE TABLE haltestellen_import (
    fid              text,
    objectid         text,
    shape            text,      -- z. B. 'POINT (16.3843 48.1835)'
    htxt             text,
    htxtk            text,
    hlinien          text,
    diva_id          text,
    ltyp             text,
    weblink1         text,
    se_anno_cad_data text
);
\copy haltestellen_import FROM 'haltestellen.csv' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
cd ~/haltestellen
psql "postgresql://gisuser:gis123@localhost:5432/hs" -f 01_staging.sql

✅ Test:

psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT count(*) AS zeilen, count(DISTINCT htxt) AS namen, count(DISTINCT shape) AS punkte FROM haltestellen_import;"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT htxt, hlinien, ltyp, shape FROM haltestellen_import WHERE htxt LIKE 'Spengergasse%';"

Erwartet: COPY 10428, dann 10428 | 3647 | 7079 und Spengergasse | 14A | 2 | POINT (16.3577… 48.1846…). Kaputte Umlaute (Straße) → ENCODING prüfen.


Schritt 6: Zieltabelle mit Geometrie und Index

ST_GeomFromText(shape, 4326) macht aus dem WKT-Text eine Geometrie mit SRID. Die Spalte ist als geometry(Point, 4326) typisiert; alles andere lehnt PostGIS ab. Die Linien werden ein Array (text[]), damit sich nach einer Linie suchen lässt.

Datei 02_haltestellen.sql:

DROP TABLE IF EXISTS haltestellen;
CREATE TABLE haltestellen (
    id       integer PRIMARY KEY,
    name     text NOT NULL,
    linien   text[],
    ltyp     smallint,
    weblink  text,
    geom     geometry(Point, 4326) NOT NULL
);

INSERT INTO haltestellen (id, name, linien, ltyp, weblink, geom)
SELECT objectid::integer,
       htxt,
       regexp_split_to_array(hlinien, '\s*,\s*'),
       ltyp::smallint,
       weblink1,
       ST_GeomFromText(shape, 4326)
FROM   haltestellen_import;

-- räumlicher Index für geometry (KNN-Suche mit <->)
CREATE INDEX haltestellen_geom_idx   ON haltestellen USING gist (geom);
-- räumlicher Index für Meter-Abfragen mit geography (ST_DWithin in m)
CREATE INDEX haltestellen_geog_idx   ON haltestellen USING gist ((geom::geography));
-- Index für "welche Haltestellen hat Linie X?"
CREATE INDEX haltestellen_linien_idx ON haltestellen USING gin (linien);
ANALYZE haltestellen;
psql "postgresql://gisuser:gis123@localhost:5432/hs" -f 02_haltestellen.sql

✅ Test:

psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT count(*), ST_SRID(geom) AS srid, GeometryType(geom) AS typ FROM haltestellen GROUP BY 2, 3;"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT count(*) FILTER (WHERE NOT ST_IsValid(geom)) AS ungueltig,
                     count(*) FILTER (WHERE NOT ST_Intersects(geom, ST_MakeEnvelope(16.1, 47.9, 16.7, 48.4, 4326))) AS ausserhalb
              FROM haltestellen;"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "\d haltestellen"

Erwartet: 10428 | 4326 | POINT, 0 | 0 (kein Punkt außerhalb eines Rechtecks um Wien – vertauschte Koordinaten fielen hier auf) und die Tabellenbeschreibung mit geom | geometry(Point,4326) und drei Indizes.


Schritt 7: Räumliche Abfragen

Bezugspunkt ist die HTL Spengergasse: ST_MakePoint(16.35713, 48.18563), Länge zuerst (woher die Zahlen kommen, zeigt Schritt 10). Die Abfragen in einer psql-Sitzung ausführen: psql "postgresql://gisuser:gis123@localhost:5432/hs"

a) Die nächsten 5 Haltepunkte. Der Operator <-> sortiert über den GiST-Index nach Entfernung (K-Nearest-Neighbour), ohne alle Distanzen zu berechnen. Die Meter kommen von geography:

SELECT name,
       array_to_string(linien, ',') AS linien,
       round(ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326)::geography)) AS meter
FROM   haltestellen
ORDER  BY geom <-> ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326)
LIMIT  5;

✅ Test:

                   name                   | linien  | meter
------------------------------------------+---------+-------
 Spengergasse                             | 14A     |   116
 Fendigasse                               | 12A     |   184
 Siebenbrunnengasse (Ramperstorffergasse) | 12A,14A |   191
 Fendigasse                               | 12A,14A |   209
 Bacherplatz                              | 59A     |   296

b) Alles im Umkreis von 500 m, pro Name zusammengefasst. ST_DWithin mit geography nimmt den Radius in Metern und nutzt haltestellen_geog_idx:

SELECT name,
       string_agg(DISTINCT l, ',' ORDER BY l) AS linien,
       round(min(ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326)::geography))) AS meter
FROM   haltestellen, unnest(linien) AS l
WHERE  ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326)::geography, 500)
GROUP  BY name
ORDER  BY meter;

✅ Test:

Erwartet: 12 Haltestellen von Spengergasse | 14A | 116 bis Kohlgasse | 12A | 496. EXPLAIN vor der Abfrage zeigt Bitmap Index Scan on haltestellen_geog_idx.

c) Alle U4-Stationen. @> („enthält“) auf dem Array nutzt den GIN-Index:

SELECT name, ST_AsText(geom)
FROM   haltestellen
WHERE  linien @> ARRAY['U4'] AND ltyp = 4
ORDER  BY ST_Y(geom) DESC;      -- von Nord nach Süd

✅ Test:

Erwartet: 19 Stationen, beginnend mit Heiligenstadt, Spittelau, Friedensbrücke.


Schritt 8: Projektionen in PostGIS nachgemessen

ST_Transform(geom, srid) rechnet eine Geometrie in ein anderes Koordinatensystem um. Damit prüfen wir die Theorie vom Anfang.

a) Derselbe Punkt in sieben Systemen

SELECT srid, ST_AsText(ST_Transform(ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326), srid)) AS koordinaten
FROM   unnest(ARRAY[4326, 3857, 31256, 31259, 31287, 32633, 4312]) AS srid;

✅ Test:

 srid  |                 koordinaten
-------+----------------------------------------------
  4326 | POINT(16.35713 48.18563)                       ← Grad (WGS 84)
  3857 | POINT(1820867.38 6137792.80)                   ← Web Mercator
 31256 | POINT(1858.49 338579.23)                       ← GK East (Wien): 1,9 km östlich von 16°20′
 31259 | POINT(751858.49 338579.23)                     ← GK M34 (NÖ Atlas): + 750 km Offset
 31287 | POINT(624783.13 480631.83)                     ← Austria Lambert
 32633 | POINT(600871.05 5337823.01)                    ← UTM 33N
  4312 | POINT(16.35833 48.18613)                       ← MGI: andere Grad-Zahlen für denselben Ort

b) Spengergasse → Stephansplatz

WITH p AS (
  SELECT ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326) AS a,   -- Spengergasse
         ST_SetSRID(ST_MakePoint(16.37208, 48.20849), 4326) AS b    -- Stephansplatz
)
SELECT round(ST_Distance(a, b)::numeric, 5)                            AS grad_4326,
       round(ST_Distance(a::geography, b::geography))                  AS m_geography,
       round(ST_Distance(ST_Transform(a, 3857),  ST_Transform(b, 3857)))  AS m_3857,
       round(ST_Distance(ST_Transform(a, 31256), ST_Transform(b, 31256))) AS m_31256,
       round(ST_Distance(ST_Transform(a, 31287), ST_Transform(b, 31287))) AS m_31287
FROM p;

✅ Test:

 grad_4326 | m_geography | m_3857 | m_31256 | m_31287
-----------+-------------+--------+---------+---------
   0.02731 |        2774 |   4165 |    2774 |    2774

geography, Gauß-Krüger und Lambert stimmen auf den Meter überein. Web Mercator liefert 4165 m statt 2774 m, genau der Faktor 1,50 aus 1/cos(48,2°).

c) „500 m Umkreis“ in vier Varianten

SELECT
  (SELECT count(*) FROM haltestellen
    WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326)::geography, 500))                        AS richtig_geography,
  (SELECT count(*) FROM haltestellen
    WHERE ST_DWithin(ST_Transform(geom, 31256), ST_Transform(ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326), 31256), 500))  AS gk_east_31256,
  (SELECT count(*) FROM haltestellen
    WHERE ST_DWithin(ST_Transform(geom, 3857),  ST_Transform(ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326), 3857), 500))   AS falsch_3857,
  (SELECT count(*) FROM haltestellen
    WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326), 500))                                             AS falsch_grad;

✅ Test:

 richtig_geography | gk_east_31256 | falsch_3857 | falsch_grad
-------------------+---------------+-------------+-------------
                20 |            20 |           8 |       10428

In 3857 sind „500 m“ in Wien nur 333 m: 8 statt 20 Haltepunkte. Mit geometry in 4326 heißt 500 „500 Grad“: die ganze Erde, alle 10.428 Zeilen. Keine der Varianten gibt eine Fehlermeldung.

d) Vertauschte Achsen und falsches Datum

-- Breite/Länge vertauscht
SELECT round(ST_Distance(ST_SetSRID(ST_MakePoint(48.18563, 16.35713), 4326)::geography,
                         ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326)::geography) / 1000) AS km_daneben;

-- dieselben Zahlen als MGI (4312) statt WGS 84 (4326) interpretiert
SELECT round(ST_Distance(ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4326)::geography,
                         ST_Transform(ST_SetSRID(ST_MakePoint(16.35713, 48.18563), 4312), 4326)::geography)) AS m_daneben;

✅ Test:

Erwartet: 4568 km (16,36° N / 48,19° O liegt im Jemen) und 105 m Datumsversatz. Koordinaten aus NÖ Atlas oder DORIS daher nie direkt mit GPS-Daten mischen, sondern mit dem richtigen SRID versehen und per ST_Transform umrechnen.


Schritt 9: Alles als Skript – der Gesamttest

Die Stadt Wien aktualisiert den Datensatz regelmäßig; das Skript hs_import.sh fasst Schritt 4–6 wiederholbar zusammen (im selben Ordner wie die SQL-Dateien):

#!/usr/bin/env bash
# Importiert "Öffentliches Verkehrsnetz Haltestellen Wien" (data.gv.at) nach PostgreSQL/PostGIS
set -euo pipefail
DB_URL="${DB_URL:-postgresql://gisuser:gis123@localhost:5432/hs}"
URL="https://data.wien.gv.at/daten/geo?service=WFS&request=GetFeature&version=1.1.0&typeName=ogdwien:OEFFHALTESTOGD&srsName=EPSG:4326&outputFormat=csv"

cd "$(dirname "$0")"
echo "1) Download ..."
curl -fsSL -o haltestellen.csv "$URL"
echo "   $(($(wc -l < haltestellen.csv) - 1)) Zeilen"

echo "2) Staging-Tabelle + \\copy ..."
psql "$DB_URL" -v ON_ERROR_STOP=1 -q -f 01_staging.sql

echo "3) Zieltabelle mit Geometrie ..."
psql "$DB_URL" -v ON_ERROR_STOP=1 -q -f 02_haltestellen.sql

echo "4) Kontrolle:"
psql "$DB_URL" -c "SELECT count(*) AS haltepunkte, count(DISTINCT name) AS namen, ST_SRID(geom) AS srid FROM haltestellen GROUP BY srid;"

set -euo pipefail bricht beim ersten Fehler ab; -v ON_ERROR_STOP=1 lässt psql auch bei SQL-Fehlern stoppen.

✅ Test:

cd ~/haltestellen
chmod +x hs_import.sh
./hs_import.sh

Erwartet: 1) Download … 10428 Zeilen, 2) und 3) ohne Fehler, Kontrolle 10428 | 3647 | 4326. Die Abfragen aus Schritt 7 liefern danach dieselben Ergebnisse.


Schritt 10: Adressen geocodieren mit Nominatim

Geocodierung macht aus einer Adresse eine Koordinate, Reverse Geocoding aus einer Koordinate eine Adresse. OpenStreetMap betreibt dafür Nominatim.

a) Im Browser. nominatim.openstreetmap.org/ui/search.html?q=spengergasse+20 zeigt die Treffer auf der Karte; details liefert Centre Point (lat,lon) 48.1855989,16.3571681, also Breite zuerst. Zu „spengergasse 20“ gibt es mehrere Treffer (Schule, Kunstwerk, Parkbank): Geocodierung ist eine Suche mit Rangfolge, kein Nachschlagen.

b) Als API. Unter /search kommt dasselbe als JSON: /ui/search.html durch /search ersetzen und format=jsonv2 anhängen. Zum Zerlegen von JSON in der Shell dient jq:

# Ubuntu / WSL
sudo apt install -y jq

# macOS (ab macOS 15 vorinstalliert, sonst:)
brew install jq
UA="hs-import-unterricht/1.0 (HTL Spengergasse, DBI)"

curl -s -A "$UA" "https://nominatim.openstreetmap.org/search?q=spengergasse+20&format=jsonv2&limit=3" \
  | jq -c '.[] | {lat, lon, category, type, display_name}'

✅ Test:

{"lat":"48.1855989","lon":"16.3571681","category":"amenity","type":"school","display_name":"HTBLuVA Spengergasse, 20, Spengergasse, Matzleinsdorf, Margareten, … Wien, 1050, Österreich"}
{"lat":"48.1861022","lon":"16.3566942","category":"tourism","type":"artwork","display_name":"Maria Theresia, 20, Spengergasse, …"}
{"lat":"48.1860463","lon":"16.3567293","category":"amenity","type":"bench","display_name":"20, Spengergasse, …"}
Parameter / FeldBedeutung
q=…Freitextsuche; Sonderzeichen URL-codiert, am einfachsten mit curl --get --data-urlencode "q=Schönbrunner Straße 1, Wien"
street=, city=, postalcode=, country=Strukturierte Suche statt q (nicht mischen)
format=jsonv2Ausgabeformat (auch geojson, xml)
limit=1Nur den besten Treffer
countrycodes=atNur Österreich
addressdetails=1Adresse zusätzlich in Einzelteilen (road, house_number, postcode …)
lat, lonKoordinaten in EPSG:4326, als Text und getrennt
display_nameWas Nominatim tatsächlich gefunden hat – immer kontrollieren

Reverse Geocoding über /reverse:

curl -s -A "$UA" "https://nominatim.openstreetmap.org/reverse?lat=48.18563&lon=16.35713&format=jsonv2&zoom=18" | jq -r '.display_name'

✅ Test:

Erwartet: 20, Spengergasse, Matzleinsdorf, Margareten, … Wien, 1050, Österreich

c) Die Spielregeln. Der öffentliche Server wird von der OSM-Community betrieben; die Nominatim Usage Policy ist verbindlich:

  • Höchstens 1 Anfrage pro Sekunde, nicht parallel. Für eine Klasse hinter derselben Schul-IP gilt das Limit gemeinsam.
  • Eigene Kennung im User-Agent, die die Anwendung beschreibt. Standardkennungen von Bibliotheken werden abgewiesen, ebenso Platzhalter wie …@example.org: Antwort 403 Forbidden.
  • Ergebnisse speichern statt dieselbe Adresse wiederholt abzufragen – das macht die Tabelle unten.
  • Kein Massen-Geocoding. Für tausende Adressen einen eigenen Nominatim-Server oder einen kommerziellen Dienst verwenden.
  • Namensnennung: ODbL, „© OpenStreetMap contributors“.

d) Geocodieren in die Datenbank. Tabelle mit Adressen, geom anfangs NULL. Datei 03_adressen.sql:

DROP TABLE IF EXISTS adressen;
CREATE TABLE adressen (
    id        integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    adresse   text NOT NULL UNIQUE,
    treffer   text,                       -- display_name von Nominatim (zur Kontrolle!)
    geom      geometry(Point, 4326)       -- NULL = noch nicht geocodiert
);
INSERT INTO adressen (adresse) VALUES
    ('Spengergasse 20, Wien'),
    ('Stephansplatz 1, Wien'),
    ('Schloss Schönbrunn, Wien'),
    ('Praterstern, Wien'),
    ('Gibtsnichtgasse 999, Wien');

geocode.sh holt alle Zeilen ohne Koordinaten, fragt Nominatim und schreibt zurück. Die Werte gehen als psql-Variablen (-v) hinein; :'name' maskiert psql als SQL-Text, wichtig bei Apostrophen in Ortsnamen.

#!/usr/bin/env bash
# Geocodiert alle Zeilen von "adressen" ohne Koordinaten über Nominatim (OpenStreetMap)
set -euo pipefail
DB_URL="${DB_URL:-postgresql://gisuser:gis123@localhost:5432/hs}"
UA="hs-import-unterricht/1.0 (HTL Spengergasse, DBI)"   # Pflicht: eigene, aussagekräftige Kennung

psql "$DB_URL" -At -F $'\t' -c "SELECT id, adresse FROM adressen WHERE geom IS NULL ORDER BY id" |
while IFS=$'\t' read -r id adresse; do
    json=$(curl -fsS -A "$UA" --get "https://nominatim.openstreetmap.org/search" \
                --data-urlencode "q=$adresse" -d format=jsonv2 -d limit=1 -d countrycodes=at)
    if [ "$(jq 'length' <<<"$json")" -eq 0 ]; then
        echo "KEIN TREFFER: $adresse"
    else
        lon=$(jq -r '.[0].lon' <<<"$json")
        lat=$(jq -r '.[0].lat' <<<"$json")
        name=$(jq -r '.[0].display_name' <<<"$json")
        psql "$DB_URL" -q -v id="$id" -v lon="$lon" -v lat="$lat" -v name="$name" <<'SQL'
UPDATE adressen
SET    geom = ST_SetSRID(ST_MakePoint(:lon, :lat), 4326),   -- Länge zuerst!
       treffer = :'name'
WHERE  id = :id;
SQL
        echo "OK: $adresse → $lon $lat"
    fi
    sleep 1        # Nominatim-Regel: höchstens 1 Anfrage pro Sekunde
done
cd ~/haltestellen
psql "postgresql://gisuser:gis123@localhost:5432/hs" -f 03_adressen.sql
chmod +x geocode.sh
./geocode.sh

✅ Test:

OK: Spengergasse 20, Wien → 16.3571681 48.1855989
OK: Stephansplatz 1, Wien → 16.3734772 48.2081643
OK: Schloss Schönbrunn, Wien → 16.3115684 48.1849894
OK: Praterstern, Wien → 16.3930785 48.2197676
KEIN TREFFER: Gibtsnichtgasse 999, Wien

Ein zweiter Aufruf fragt nur noch die Adresse ohne Treffer ab. curl: (22) … error: 403 heißt: Kennung abgelehnt oder Limit überschritten.

e) Nächste Haltestelle je Adresse. CROSS JOIN LATERAL führt die KNN-Suche aus Schritt 7 einmal pro Adresse aus:

SELECT a.adresse,
       h.name AS naechste_haltestelle, array_to_string(h.linien, ',') AS linien,
       round(ST_Distance(a.geom::geography, h.geom::geography)) AS meter
FROM   adressen a
CROSS  JOIN LATERAL (
         SELECT name, linien, geom
         FROM   haltestellen
         ORDER  BY geom <-> a.geom
         LIMIT  1
       ) h
WHERE  a.geom IS NOT NULL
ORDER  BY a.id;

✅ Test:

         adresse          |           naechste_haltestelle           |  linien   | meter
--------------------------+------------------------------------------+-----------+-------
 Spengergasse 20, Wien    | Spengergasse                             | 14A       |   112
 Stephansplatz 1, Wien    | Stephansplatz                            | 3A        |   100
 Schloss Schönbrunn, Wien | Schloss Schönbrunn (Schönbr. Schloßstr.) | N60       |   261
 Praterstern, Wien        | Praterstern (Anitta-Müller-Cohen-Platz)  | 82A,SV900 |    65

Die „nächste Haltestelle“ ist der nächste Haltepunkt, beim Stephansplatz also der Bus 3A; wer nur U-Bahn will, ergänzt WHERE ltyp = 4. Die Spalte treffer zeigt, was Nominatim gefunden hat – bei unscharfen Adressen kann das ein anderes Objekt sein.


Schritt 11: Anwendungsfall QGIS – Views als Kartenlayer

QGIS lädt PostGIS-Tabellen und Views direkt als Layer: Jede Zeile mit Geometrie wird zu Punkt, Linie oder Fläche. Die Logik bleibt in SQL; ändert sich eine Adresse in der Datenbank, zeigt QGIS nach F5 die neue Karte. Drei Views ergeben eine Erreichbarkeitskarte:

ViewGeometrieZeigt
v_stationenPunkt (Mittelpunkt aller Haltepunkte einer Station)Eine Zeile pro Station mit allen Linien und einer Kategorie (U-Bahn, S-Bahn, Straßenbahn, Badner Bahn, Bus) fürs Symbol
v_adressen_umkreisFläche (Kreis, 500 m)Umkreis um jede Adresse aus Schritt 10 mit Anzahl der Stationen und Linien darin
v_naechste_ubahnLinieLuftlinie von jeder Adresse zur nächsten U-Bahn-Station mit Entfernung

Zwei Dinge braucht QGIS: einen eindeutigen Schlüssel (bei Views die Spalte id, beim Laden anzugeben) und einen erkennbaren Geometrietyp: ST_Centroid(…) liefert nur „Geometrie“; der Cast ::geometry(Point, 4326) legt Typ und SRID fest, und die View erscheint in geometry_columns.

Datei 04_views.sql:

-- 1) Eine Zeile pro Station: Haltepunkte zusammenfassen
DROP VIEW IF EXISTS v_stationen CASCADE;
CREATE VIEW v_stationen AS
SELECT min(id)                                         AS id,          -- QGIS braucht einen eindeutigen Schlüssel
       name,
       count(*)                                        AS haltepunkte,
       array_to_string(array_agg(DISTINCT l ORDER BY l), ', ') AS linien,
       bool_or(ltyp = 4)                               AS ubahn,
       bool_or(ltyp = 1)                               AS strassenbahn,
       bool_or(ltyp = 5)                               AS sbahn,
       bool_or(ltyp IN (2, 3))                         AS bus,
       bool_or(ltyp BETWEEN 10 AND 13)                 AS nachtbus,
       CASE WHEN bool_or(ltyp = 4) THEN 'U-Bahn'
            WHEN bool_or(ltyp = 5) THEN 'S-Bahn'
            WHEN bool_or(ltyp = 1) THEN 'Straßenbahn'
            WHEN bool_or(ltyp = 9) THEN 'Badner Bahn'
            ELSE 'Bus' END                             AS kategorie,   -- "höchstes" Verkehrsmittel → Symbol in QGIS
       ST_Centroid(ST_Collect(geom))::geometry(Point, 4326) AS geom
FROM   haltestellen, unnest(linien) AS l
GROUP  BY name;

-- 2) Umkreis um jede Adresse (500 m, als Fläche) mit Anzahl der Stationen darin
DROP VIEW IF EXISTS v_adressen_umkreis;
CREATE VIEW v_adressen_umkreis AS
SELECT a.id, a.adresse,
       (SELECT count(*) FROM v_stationen s
         WHERE ST_DWithin(s.geom::geography, a.geom::geography, 500)) AS stationen_500m,
       (SELECT string_agg(DISTINCT l, ', ' ORDER BY l)
          FROM haltestellen h, unnest(h.linien) AS l
         WHERE ST_DWithin(h.geom::geography, a.geom::geography, 500)) AS linien_500m,
       ST_Buffer(a.geom::geography, 500)::geometry(Polygon, 4326) AS geom
FROM   adressen a
WHERE  a.geom IS NOT NULL;

-- 3) Luftlinie von jeder Adresse zur nächsten U-Bahn-Station
DROP VIEW IF EXISTS v_naechste_ubahn;
CREATE VIEW v_naechste_ubahn AS
SELECT a.id, a.adresse, s.name AS station, s.linien,
       round(ST_Distance(a.geom::geography, s.geom::geography)) AS meter,
       ST_MakeLine(a.geom, s.geom)::geometry(LineString, 4326) AS geom
FROM   adressen a
CROSS  JOIN LATERAL (
         SELECT name, linien, geom FROM v_stationen
         WHERE  ubahn
         ORDER  BY geom <-> a.geom
         LIMIT  1
       ) s
WHERE  a.geom IS NOT NULL;

unnest(linien) löst das Array auf, array_agg(DISTINCT …) sammelt die Linien aller Haltepunkte einer Station ohne Doppelte. bool_or(ltyp = 4) ist wahr, sobald ein Haltepunkt eine U-Bahn ist; das CASE wählt das „höchste“ Verkehrsmittel als Kategorie. Die Haltepunkte einer Station liegen höchstens einige hundert Meter auseinander, der Schwerpunkt ist also ein brauchbarer Stationsmittelpunkt.

cd ~/haltestellen
psql "postgresql://gisuser:gis123@localhost:5432/hs" -f 04_views.sql

✅ Test:

psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT f_table_name, type, srid FROM geometry_columns WHERE f_table_name LIKE 'v\\_%';"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT kategorie, count(*) FROM v_stationen GROUP BY 1 ORDER BY 2 DESC;"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT adresse, station, meter FROM v_naechste_ubahn ORDER BY id;"
    f_table_name    |    type    | srid
--------------------+------------+------
 v_adressen_umkreis | POLYGON    | 4326
 v_naechste_ubahn   | LINESTRING | 4326
 v_stationen        | POINT      | 4326

  kategorie  | count
-------------+-------
 Bus         |  2935
 Straßenbahn |   477
 S-Bahn      |   120
 U-Bahn      |    99
 Badner Bahn |    16

         adresse          |    station    | meter
--------------------------+---------------+-------
 Spengergasse 20, Wien    | Pilgramgasse  |   829
 Stephansplatz 1, Wien    | Stephansplatz |   120
 Schloss Schönbrunn, Wien | Schönbrunn    |   597
 Praterstern, Wien        | Praterstern   |   117

Aus 10.428 Haltepunkten werden 3.647 Stationen, 99 davon mit U-Bahn. Von der Spengergasse ist die Pilgramgasse (U4) die nächste, 829 m Luftlinie.

Abfrage: die nächsten 10 Stationen zu einer Adresse

Mit adressen (Schritt 10) und v_stationen lässt sich die KNN-Suche aus Schritt 7 auf Stationen statt Haltepunkte anwenden – sonst erscheint „Fendigasse“ zweimal:

SELECT s.name, s.kategorie, s.linien,
       round(ST_Distance(s.geom::geography, a.geom::geography)) AS meter
FROM   adressen a
CROSS  JOIN LATERAL (
         SELECT * FROM v_stationen ORDER BY geom <-> a.geom LIMIT 20   -- Vorauswahl über den Index
       ) s
WHERE  a.adresse = 'Spengergasse 20, Wien'
ORDER  BY meter
LIMIT  10;

✅ Test:

                   name                   | kategorie |    linien     | meter
------------------------------------------+-----------+---------------+-------
 Spengergasse                             | Bus       | 14A           |   112
 Siebenbrunnengasse (Ramperstorffergasse) | Bus       | 12A, 14A      |   193
 Fendigasse                               | Bus       | 12A, 14A      |   198
 Bacherplatz                              | Bus       | 12A, 14A, 59A |   316
 Reinprechtsdorfer Straße/Arbeitergasse   | Bus       | 12A, 14A, 59A |   329
 Scalagasse                               | Bus       | 14A           |   372
 Laurenzgasse                             | Bus       | N62           |   419
 Bacherplatz (Margaretenstraße)           | Bus       | 59A           |   429
 Laurenzgasse (Tiefgeschoß)               | Bus       | 1, 62, WLB    |   432
 Siebenbrunnenfeldgasse                   | Bus       | 12A           |   473

ORDER BY geom <-> a.geom LIMIT 20 liest über den GiST-Index nur die nächsten Zeilen statt alle 3.647 zu sortieren. Warum 20 und nicht 10? <-> misst in Grad, und ein Längengrad ist in Wien um ein Drittel kürzer als ein Breitengrad: Die Index-Reihenfolge ist nach Osten und Westen „zu optimistisch“. Mit LIMIT 10 direkt im Index fehlen die Laurenzgasse (419 m) und ihr Tiefgeschoß (432 m), dafür stehen Stationen mit 550 und 559 m in der Liste. Deshalb: mehr Kandidaten holen, nach echten Metern sortieren, dann abschneiden. In Schritt 12 wird daraus eine Funktion.

QGIS installieren

QGIS Installation Guide – Langzeitversion 3.44 LTR oder aktuelles 4.x, beides funktioniert.

SystemInstallationDirekter Download (qgis.org)
Ubuntu / WSLDebian/Ubuntu (eigenes APT-Repository). Unter WSL ist die Windows-Version einfacher; sie verbindet sich über 127.0.0.1 mit der Datenbank in WSL (W5).–
WindowsWindowsQGIS-OSGeo4W-3.44.15-1.msi (579 MB)
macOSmacOSqgis_ltr_final-3_44_15.dmg (1,5 GB)

Fertiges Projekt öffnen

Die Projektdatei haltestellen.qgz (in haltestellen-skripte-v2.zip) enthält alle Layer in Gruppen (Erreichbarkeit, Funktionen, Analyse, Stadt Wien (OGD), Rohdaten; nur die erste ist anfangs eingeblendet), Symbole und Beschriftungen und verbindet sich mit 127.0.0.1:5432, Datenbank hs, Benutzer gisuser, Passwort gis123 (Schritt 3 unverändert vorausgesetzt). Herunterladen, in QGIS Projekt → Öffnen:

QGIS-Karte von Wien: Stationen als farbige Punkte nach Verkehrsmittel, orange 500-m-Kreise um fünf Adressen, rote Linien zur jeweils nächsten U-Bahn-Station, OpenStreetMap im Hintergrund.
Das Projekt haltestellen.qgz: Stationen nach Verkehrsmittel, 500-m-Umkreise um die geocodierten Adressen, Luftlinie zur nächsten U-Bahn. Hintergrund © OpenStreetMap contributors.

✅ Test:

Erwartet: Alle Stationen Wiens als Punkte, fünf orange Kreise und rote Linien zur nächsten U-Bahn auf der OSM-Karte. Fragt QGIS nach einem Passwort, stimmt gis123 nicht mehr. Liegen die Punkte im Jemen, sind Länge und Breite vertauscht (Schritt 6 bzw. 10).

Was QGIS an die Datenbank schickt

Im Projekt steht kein SQL. Jeder Layer kennt nur Tabelle, Geometriespalte und Schlüssel (table="public"."v_stationen" (geom) key='id'); der PostgreSQL-Provider von QGIS baut daraus bei jedem Zeichnen, Klicken oder Filtern ein SELECT. Mitgeschnitten auf dem PostgreSQL-Server – beim Zeichnen nur die Spalten für Symbol und Beschriftung und nur der sichtbare Ausschnitt (&& ist der Index-Operator „Bounding-Box überlappt“, hier zahlt sich der GiST-Index aus Schritt 6 aus):

BEGIN READ ONLY;
DECLARE qgis_1 BINARY CURSOR FOR
SELECT st_asbinary("geom",'NDR'), "id", "kategorie"::text, "name"::text
FROM   "public"."v_stationen"
WHERE  ("geom" && st_makeenvelope(16.29, 48.1675, 16.42, 48.2325, 4326))
  AND  ("kategorie" IN ('U-Bahn','S-Bahn','Straßenbahn','Badner Bahn','Bus'));
FETCH FORWARD 2000 FROM qgis_1;

Beim Identifizieren eines Objekts alle Spalten, aber nur eine Zeile:

SELECT st_asbinary("geom",'NDR'), "id", "name"::text, "haltepunkte", "linien"::text, …
FROM   "public"."v_stationen"
WHERE  ("name" = 'Spengergasse');

Beim Öffnen des Projekts gehen zuvor Metadaten-Abfragen über die Leitung: postgis_version(), Rechteprüfungen (has_table_privilege) und bei Views SELECT count(distinct "id") = count("id") FROM "public"."v_stationen" – deshalb braucht jede View die eindeutige Spalte id.

Wo man das in QGIS sieht:

  • Ansicht → Bedienfelder → Entwicklungswerkzeuge, Reiter Abfrageprotokoll: jedes SELECT live mit Laufzeit, sobald man die Karte verschiebt.
  • Layer-Eigenschaften → Information: die Datenquelle, also das, was QGIS über den Layer kennt.
  • Layer-Eigenschaften → Quelle → Objektfilter: eine eigene WHERE-Bedingung (z. B. ubahn), die QGIS an seine Abfragen anhängt.
  • Datenbank → DB-Verwaltung → SQL-Fenster: eigenes SQL, etwa die Abfrage der nächsten 10 Stationen, mit Als neuen Layer laden direkt auf die Karte – für Abfragen, die nicht als View in der Datenbank liegen sollen.

Serverseitig zeigt das PostgreSQL-Log dieselben Abfragen:

# als Superuser: alle Statements von gisuser protokollieren
psql postgres -c "ALTER ROLE gisuser SET log_statement = 'all';"
# … in QGIS Karte verschieben, dann:
tail -f /var/log/postgresql/postgresql-18-main.log        # Ubuntu / WSL
tail -f /opt/homebrew/var/log/postgresql@18.log           # macOS (Homebrew)
# wieder abschalten
psql postgres -c "ALTER ROLE gisuser RESET log_statement;"

✅ Test:

Abfrageprotokoll öffnen, Karte verschieben.

Erwartet: eine neue Zeile SELECT … FROM "public"."v_stationen" WHERE ("geom" && st_makeenvelope(…)) pro Layer und Bewegung; dieselben Zeilen im PostgreSQL-Log mit LOG: statement: BEGIN READ ONLY;DECLARE qgis_… BINARY CURSOR FOR SELECT ….

Von Hand: Datenbank verbinden und Views laden

Zwei Dinge vorweg: Die Verbindungsliste in der Datenquellenverwaltung ist leer, auch wenn haltestellen.qgz offen ist – gespeicherte Verbindungen sind QGIS-Einstellungen am Rechner, keine Projektdaten; die Projekt-Layer tragen ihre Verbindungsdaten selbst mit. Und der Eintrag PostgreSQL (Elefant) steht in der linken Liste der Datenquellenverwaltung zwischen den Dateiformaten (GeoPackage, SpatiaLite) und den Web-Diensten; derselbe Dialog ist im Browser-Panel unter PostgreSQL → Rechtsklick → Neue Verbindung erreichbar.

  • Layer → Datenquellenverwaltung (Strg+L) → PostgreSQL → Neu: Name hs, Host 127.0.0.1, Port 5432, Datenbank hs, unter Authentifizierung → Basic Benutzer gisuser, Passwort gis123. Verbindung testen → OK.
  • Verbinden: Im Schema public erscheinen Tabellen und Views mit Geometrietyp. Bei den Views in der Spalte Objekt-ID id wählen; ohne Schlüssel kann QGIS keine Objekte auswählen oder identifizieren.
  • Die drei Views markieren → Hinzufügen. Reihenfolge im Layer-Panel von oben: v_naechste_ubahn, v_stationen, v_adressen_umkreis.
  • Hintergrund: im Browser-Panel unter XYZ Tiles auf OpenStreetMap doppelklicken, Layer nach unten ziehen.
  • v_stationen gestalten: Eigenschaften → Symbolisierung → Kategorisiert, Wert kategorie, Klassifizieren; Bus klein (1,5), U-Bahn groß (4). Beschriftungen: name, maßstabsabhängig ab 1:10.000.
  • v_adressen_umkreis: Deckkraft 30 %, Beschriftung adresse || '\n' || stationen_500m || ' Stationen'. v_naechste_ubahn: gestrichelt, Beschriftung station || ' (' || meter || ' m)'.

Live: Adresse ergänzen, Karte aktualisieren

psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "INSERT INTO adressen (adresse) VALUES ('Karlsplatz, Wien');"
./geocode.sh

✅ Test:

In QGIS F5 drücken.

Erwartet: Ein Kreis am Karlsplatz (22 Stationen im Umkreis) und eine 90 m kurze Linie zur U-Bahn-Station Karlsplatz. Die Views rechnen bei jeder Anzeige neu.

Probe aufs Exempel: Rechts unten auf das Projekt-KBS klicken und EPSG:4326 wählen – die Kreise werden zu Ellipsen. Mit EPSG:3857 oder EPSG:31256 sind es wieder Kreise. Das Messwerkzeug misst ellipsoidisch, also wie geography, unabhängig vom angezeigten KBS.


Schritt 12: Funktionen, Analyse-Views und Daten der Stadt Wien

Alles, was QGIS anzeigt, kommt über Port 5432 aus der Datenbank. Neben Tabellen und Views geht das auch mit Funktionen: QGIS kann eine beliebige SELECT-Abfrage als Layer laden (Query-Layer), also auch SELECT * FROM meine_funktion('Parameter'). Die Logik – und die Parameter – stehen dann im SQL, nicht in QGIS.

Funktion: die nächsten n Stationen zu einer Adresse

naechste_stationen(adresse, n) nimmt den Adressnamen aus Schritt 10 und liefert die n nächsten Stationen mit Rang, Entfernung und zwei Geometrien: den Stationspunkt und die Luftlinie von der Adresse dorthin. Die zweite Funktion stationen_im_umkreis(lon, lat, meter) braucht keine gespeicherte Adresse, sondern eine Koordinate. Dazu zwei Analyse-Views: v_linien (Einzugsgebiet jeder Linie als gepufferte konvexe Hülle um ihre Haltepunkte) und v_dichte (Stationen je 500-m-Sechseck, gerechnet in EPSG:31256, weil ein Sechseckgitter nur in einem metrischen System gleich große Zellen hat). Datei 06_funktionen.sql:

-- Funktionen und Views, die QGIS direkt über Port 5432 abfragt

-- 1) Die nächsten n Stationen zu einer gespeicherten Adresse (Name wie in Tabelle adressen)
CREATE OR REPLACE FUNCTION naechste_stationen(p_adresse text, p_n integer DEFAULT 10)
RETURNS TABLE (
    rang        integer,
    station     text,
    kategorie   text,
    linien      text,
    meter       integer,
    geom        geometry(Point, 4326),        -- die Station
    verbindung  geometry(LineString, 4326)    -- Luftlinie Adresse → Station
)
LANGUAGE sql STABLE AS $$
    SELECT row_number() OVER (ORDER BY s.meter)::integer AS rang,
           s.name, s.kategorie, s.linien, s.meter, s.geom,
           ST_MakeLine(a.geom, s.geom)::geometry(LineString, 4326)
    FROM   adressen a
    CROSS  JOIN LATERAL (
             SELECT name, kategorie, linien, geom,
                    round(ST_Distance(geom::geography, a.geom::geography))::integer AS meter
             FROM   v_stationen
             ORDER  BY geom <-> a.geom       -- KNN über den Index (in Grad, nur Vorauswahl) ...
             LIMIT  p_n * 2                  -- ... deshalb doppelt so viele holen
           ) s
    WHERE  a.adresse ILIKE p_adresse        -- 'Spengergasse%' reicht
      AND  a.geom IS NOT NULL
    ORDER  BY s.meter                       -- ... und nach echten Metern sortieren
    LIMIT  p_n;
$$;

-- 2) Stationen im Umkreis einer beliebigen Koordinate (Länge, Breite, Radius in m)
CREATE OR REPLACE FUNCTION stationen_im_umkreis(p_lon double precision, p_lat double precision, p_meter integer DEFAULT 500)
RETURNS TABLE (id integer, station text, kategorie text, linien text, meter integer, geom geometry(Point, 4326))
LANGUAGE sql STABLE AS $$
    SELECT s.id, s.name, s.kategorie, s.linien,
           round(ST_Distance(s.geom::geography, p::geography))::integer,
           s.geom
    FROM   v_stationen s,
           (SELECT ST_SetSRID(ST_MakePoint(p_lon, p_lat), 4326) AS p) q
    WHERE  ST_DWithin(s.geom::geography, p::geography, p_meter)
    ORDER  BY 5;
$$;

-- 3) Einzugsgebiet je Linie: konvexe Hülle um alle Haltepunkte, 50 m gepuffert (immer ein Polygon)
DROP VIEW IF EXISTS v_linien;
CREATE VIEW v_linien AS
SELECT row_number() OVER (ORDER BY linie)::integer AS id,
       linie,
       count(*)                                     AS haltepunkte,
       count(DISTINCT name)                         AS stationen,
       min(ltyp)                                    AS ltyp,
       ST_Buffer(ST_ConvexHull(ST_Collect(geom))::geography, 50)::geometry(Polygon, 4326) AS geom
FROM   haltestellen, unnest(linien) AS linie
GROUP  BY linie;

-- 4) Versorgungsdichte: Stationen je 500-m-Sechseck (für eine Heatmap in QGIS)
DROP VIEW IF EXISTS v_dichte;
CREATE VIEW v_dichte AS
WITH hex AS (
    SELECT (ST_HexagonGrid(500, ST_Transform(ST_SetSRID(ST_Extent(geom), 4326), 31256))).*
    FROM   haltestellen
)
SELECT row_number() OVER ()::integer      AS id,
       count(s.id)                        AS stationen,
       count(s.id) FILTER (WHERE s.ubahn) AS ubahn,
       ST_Transform(hex.geom, 4326)::geometry(Polygon, 4326) AS geom
FROM   hex
JOIN   v_stationen s ON ST_Contains(hex.geom, ST_Transform(s.geom, 31256))
GROUP  BY hex.geom;

Der Trick in naechste_stationen: ORDER BY geom <-> a.geom sortiert über den Index, aber in Grad – und ein Grad Länge ist in Wien kürzer als ein Grad Breite. Die Index-Reihenfolge weicht deshalb von der Meter-Reihenfolge leicht ab. Die Funktion holt darum die doppelte Kandidatenzahl über den Index und sortiert dann nach echten Metern.

cd ~/haltestellen
psql "postgresql://gisuser:gis123@localhost:5432/hs" -f 06_funktionen.sql

✅ Test:

psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT rang, station, kategorie, linien, meter FROM naechste_stationen('Spengergasse%');"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT count(*) FROM stationen_im_umkreis(16.35717, 48.18560, 500);"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT linie, stationen FROM v_linien WHERE linie ~ '^U[1-6]$' ORDER BY linie;"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT count(*) AS zellen, max(stationen) FROM v_dichte;"
 rang |                 station                  | kategorie |    linien     | meter
------+------------------------------------------+-----------+---------------+-------
    1 | Spengergasse                             | Bus       | 14A           |   112
    2 | Siebenbrunnengasse (Ramperstorffergasse) | Bus       | 12A, 14A      |   193
    3 | Fendigasse                               | Bus       | 12A, 14A      |   198
    4 | Bacherplatz                              | Bus       | 12A, 14A, 59A |   316
    5 | Reinprechtsdorfer Straße/Arbeitergasse   | Bus       | 12A, 14A, 59A |   329
    6 | Scalagasse                               | Bus       | 14A           |   372
    7 | Laurenzgasse                             | Bus       | N62           |   419
    8 | Bacherplatz (Margaretenstraße)           | Bus       | 59A           |   429
    9 | Laurenzgasse (Tiefgeschoß)               | Bus       | 1, 62, WLB    |   432
   10 | Siebenbrunnenfeldgasse                   | Bus       | 12A           |   473

 count: 12       U1 23 · U2 21 · U3 20 · U4 19 · U6 22 Stationen       zellen: 1070, max: 24

Funktionen als Query-Layer in QGIS

Im Menü Datenbank → DB-Verwaltung die Verbindung hs öffnen, SQL-Fenster (F2) und eingeben:

SELECT * FROM naechste_stationen('Spengergasse%', 10);

Ausführen zeigt die Tabelle. Darunter „Als neuen Layer laden“ ankreuzen, Spalte mit eindeutigen Werten rang, Geometriespalte geom (oder verbindung für die Luftlinien), Laden. QGIS speichert dann als Datenquelle des Layers nicht eine Tabelle, sondern das SELECT in Klammern:

table="(SELECT * FROM naechste_stationen('Spengergasse%', 10))" (geom) key='rang'

Das steht so auch in haltestellen.qgz (Gruppe Funktionen). Anderen Parameter? Rechtsklick auf den Layer → Datenquelle ändern, Spengergasse% durch Karlsplatz% ersetzen – oder in der DB-Verwaltung neu laden.

QGIS-Karte um die Spengergasse: zehn nummerierte Stationen mit Entfernungsangabe und gepunkteten Luftlinien zur Adresse, OpenStreetMap im Hintergrund.
Query-Layer aus naechste_stationen(‚Spengergasse%‘, 10): Stationen mit Rang und Metern, Luftlinien aus der zweiten Geometriespalte. Hintergrund © OpenStreetMap contributors.

✅ Test:

Erwartet: Zehn nummerierte Punkte um die Spengergasse mit Luftlinien zum Stern der Adresse. Im Abfrageprotokoll (Schritt 11) erscheint bei jedem Zeichnen SELECT … FROM (SELECT * FROM naechste_stationen(…)) WHERE ("geom" && st_makeenvelope(…)) – QGIS filtert auch Funktionsergebnisse nach dem Kartenausschnitt.

Daten der Stadt Wien als zusätzliche Layer

Derselbe WFS liefert hunderte weitere Layer (GetCapabilities listet sie). Vier davon passen zu den Haltestellen; wien_layer.sh lädt sie als GeoJSON und 05_wien_layer.sql importiert sie über eine jsonb-Staging-Tabelle mit jsonb_array_elements und ST_GeomFromGeoJSON – alles Datenquelle: Stadt Wien – data.wien.gv.at, CC BY 4.0:

WFS-LayerTabelleGeometrieZeilenAttribute
BEZIRKSGRENZEOGDwien_bezirkeMultiPolygon23bezirk, label (I.–XXIII.), flaeche_m2
OEFFLINIENOGDwien_linienMultiLineString4.919linien, linien_arr (text[]), ltyp (gleiche Codes wie haltestellen), ltyptxt
SCHULEOGDwien_schulenPoint806name, adresse, bezirk, art_txt, skz
FAHRRADABSTELLANLAGEOGDwien_radabstellPoint8.511bezirk, adresse, anzahl (Stellplätze), art_txt

wien_linien ist das Gegenstück zu unseren Haltepunkten: die Linienführung als Geometrie, mit ltyptxt als Klartext der LTYP-Codes (1 Straßenbahn, 2 Stadtbus, 3 Regionalbus, 4 U-Bahn, 5 S-Bahn/Zug, 6 Badner Bahn, 10–13 Nacht- und Rufbus) – das bestätigt die Zuordnung vom Anfang. Zwei Views verknüpfen die neuen Tabellen mit den Haltestellen (Teil von 05_wien_layer.sql):

-- Haltepunkte je Bezirk (Choroplethenkarte)
CREATE VIEW v_wien_bezirk_stationen AS
SELECT b.id, b.bezirk, b.label,
       count(h.id)                                      AS haltepunkte,
       count(DISTINCT h.name)                           AS stationen,
       count(DISTINCT h.name) FILTER (WHERE h.ltyp = 4) AS ubahn_stationen,
       round(b.flaeche_m2 / 1e6, 2)                     AS km2,
       round(count(h.id) / (b.flaeche_m2 / 1e6), 1)     AS haltepunkte_pro_km2,
       (SELECT count(*) FROM wien_schulen s WHERE ST_Contains(b.geom, s.geom))                     AS schulen,
       (SELECT coalesce(sum(anzahl), 0) FROM wien_radabstell r WHERE ST_Contains(b.geom, r.geom)) AS radstellplaetze,
       b.geom::geometry(MultiPolygon, 4326) AS geom
FROM   wien_bezirke b
LEFT   JOIN haltestellen h ON ST_Contains(b.geom, h.geom)
GROUP  BY b.id, b.bezirk, b.label, b.flaeche_m2, b.geom;

-- Jede Schule und ihr nächster Haltepunkt (Luftlinie)
CREATE VIEW v_wien_schule_station AS
SELECT s.id, s.name AS schule, s.art_txt, s.adresse,
       h.name AS station, array_to_string(h.linien, ', ') AS linien,
       round(ST_Distance(s.geom::geography, h.geom::geography)) AS meter,
       ST_MakeLine(s.geom, h.geom)::geometry(LineString, 4326) AS geom
FROM   wien_schulen s
CROSS  JOIN LATERAL (SELECT name, linien, geom FROM haltestellen ORDER BY geom <-> s.geom LIMIT 1) h;
cd ~/haltestellen
chmod +x wien_layer.sh
./wien_layer.sh            # Download + Import; "./wien_layer.sh --no-dl" importiert vorhandene JSON-Dateien

✅ Test:

psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT f_table_name, type FROM geometry_columns WHERE f_table_name LIKE 'wien%' OR f_table_name LIKE 'v_wien%' ORDER BY 1;"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT bezirk, label, haltepunkte, stationen, ubahn_stationen, km2, haltepunkte_pro_km2, schulen FROM v_wien_bezirk_stationen ORDER BY haltepunkte_pro_km2 DESC LIMIT 3;"
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT round(avg(meter)) AS schnitt, max(meter) AS maximum FROM v_wien_schule_station;"
 wien_bezirke MULTIPOLYGON · wien_linien MULTILINESTRING · wien_radabstell POINT · wien_schulen POINT
 v_wien_bezirk_stationen MULTIPOLYGON · v_wien_schule_station LINESTRING

    bezirk    | label | haltepunkte | stationen | ubahn_stationen | km2  | haltepunkte_pro_km2 | schulen
--------------+-------+-------------+-----------+-----------------+------+---------------------+---------
 Neubau       | VII.  |         151 |        35 |               5 | 1.61 |                93.9 |      20
 Innere Stadt | I.    |         204 |        72 |               7 | 2.87 |                71.1 |      15
 Alsergrund   | IX.   |         178 |        46 |               7 | 2.97 |                60.0 |      21

 schnitt: 135   maximum: 607

Neubau hat die höchste Haltepunktdichte Wiens; eine Wiener Schule ist im Schnitt 135 m, höchstens 607 m vom nächsten Haltepunkt entfernt. Im Projekt liegen diese Layer in der Gruppe Stadt Wien (OGD): Bezirke mit Dichte-Beschriftung, U-Bahn- und Straßenbahnlinien (aus wien_linien, gefiltert über ltyp), Schulen mit Luftlinie zur Haltestelle, Fahrradabstellanlagen.

QGIS-Karte von ganz Wien: Bezirksgrenzen in Blau mit Beschriftung der Haltepunkte je km², U-Bahn-Linien in Rot, grün abgestufte Sechsecke der Stationsdichte, OpenStreetMap im Hintergrund.
Gruppe Analyse und Stadt Wien: Bezirke (v_wien_bezirk_stationen) mit Haltepunkten je km², U-Bahn-Linien aus wien_linien, Stationsdichte je 500-m-Sechseck (v_dichte). Hintergrund © OpenStreetMap contributors.

Zum Weiterbauen: wien_linien.linien_arr und haltestellen.linien sind beide text[] – WHERE linien_arr @> ARRAY['U4'] liefert die Linienführung der U4, ST_DWithin zwischen Schulen und Linien die Schulen entlang einer Linie, und wien_radabstell gegen v_stationen die Stationen ohne Radabstellplatz im Umkreis.


Alle Dateien

haltestellen-skripte-v2.zip enthält 01_staging.sql bis 06_funktionen.sql, hs_import.sh, geocode.sh, wien_layer.sh, haltestellen.qgz und eine README mit der Reihenfolge. Nach ~/haltestellen entpacken; die Daten holen die Skripte selbst (CSV-Kopie siehe Schritt 4).


Windows: das Ganze mit WSL 2

Unter Windows läuft PostgreSQL im Windows-Subsystem für Linux (WSL 2), einer von Windows verwalteten Linux-VM mit Ubuntu 24.04. Schritt 1–12 (Ubuntu) gelten darin unverändert; hier steht nur, was unter Windows zusätzlich zu tun ist, und wie pgAdmin oder QGIS unter Windows die Datenbank in WSL erreichen.

  • Alles in WSL: PostgreSQL, PostGIS, curl, jq, psql und die Skripte laufen in Ubuntu; sie verbinden sich mit localhost:5432.
  • Windows 11 (22H2+): Netzwerkmodus mirrored; Windows und WSL teilen sich 127.0.0.1. Windows 10: Standardmodus NAT mit localhost-Forwarding, funktioniert in der Regel ebenfalls über 127.0.0.1:5432.
  • An der PostgreSQL-Konfiguration ändert sich im Normalfall nichts. Das Projekt liegt im Linux-Dateisystem unter ~/haltestellen, nicht unter /mnt/c/….

In den Codeblöcken steht, wo ein Befehl läuft: # PowerShell (Windows) oder # in WSL (Ubuntu).

W1: WSL 2 und Ubuntu 24.04 installieren

Voraussetzung: Windows 10 ab Build 19041 oder Windows 11, Virtualisierung im BIOS/UEFI aktiv. In einer PowerShell als Administrator:

# PowerShell (als Administrator)
wsl --install -d Ubuntu-24.04
# danach Windows neu starten, falls verlangt
wsl --update

Nach dem Neustart fragt Ubuntu nach einem Linux-Benutzernamen und Passwort; das Passwort braucht man für sudo.

✅ Test:

# PowerShell
wsl --version
wsl -l -v

Erwartet: WSL-Version 2.x; Ubuntu-24.04 mit VERSION 2. Wird wsl --version nicht erkannt → wsl --update. Steht VERSION 1 → wsl --set-version Ubuntu-24.04 2.

W2: systemd aktivieren und Netzwerkmodus setzen

Damit PostgreSQL als Dienst mit der VM startet, muss in WSL systemd laufen. Prüfen mit cat /etc/wsl.conf; fehlt [boot] systemd=true, ergänzen:

# in WSL (Ubuntu)
sudo tee -a /etc/wsl.conf > /dev/null <<'EOF'
[boot]
systemd=true
EOF

Netzwerkmodus (nur Windows 11 22H2+): in der Windows-Datei %UserProfile%\.wslconfig, die anfangs nicht existiert. Beim Speichern mit Notepad „Alle Dateien“ wählen, damit die Datei nicht .wslconfig.txt heißt.

# PowerShell – öffnet bzw. erzeugt die Datei
notepad "$env:USERPROFILE\.wslconfig"
# Inhalt von %UserProfile%\.wslconfig  (nur Windows 11 22H2+)
[wsl2]
networkingMode=mirrored

Beide Änderungen wirken erst nach einem vollständigen Neustart der VM:

# PowerShell
wsl --shutdown
# ca. 8 Sekunden warten, dann Ubuntu wieder öffnen
wsl -d Ubuntu-24.04

✅ Test:

# in WSL (Ubuntu)
ps -p 1 -o comm=
systemctl is-system-running
wslinfo --networking-mode  # nur bei neueren WSL-Versionen

Erwartet: systemd; running oder degraded (in WSL häufig und harmlos); mirrored bzw. nat.

W3: PostgreSQL, PostGIS und die Datenbank in WSL

Schritt 1–3 (Ubuntu) in der WSL-Shell ausführen, samt Tests. Ein Stolperstein: Ein natives Windows-PostgreSQL (z. B. EDB-Installer aus einem früheren Kurs) belegt ebenfalls Port 5432 – im Mirrored-Modus kann das WSL-PostgreSQL den Port dann nicht belegen, im NAT-Modus landen Windows-Programme beim Windows-PostgreSQL. Vorher prüfen:

# PowerShell
Get-Service *postgres*

Läuft ein Dienst wie postgresql-x64-16: Stop-Service postgresql-x64-16; Set-Service postgresql-x64-16 -StartupType Manual (als Administrator).

✅ Test:

# in WSL (Ubuntu)
pg_lsclusters
systemctl is-enabled postgresql
sudo ss -ltnp | grep 5432
psql "postgresql://gisuser:gis123@localhost:5432/hs" -c "SELECT postgis_full_version();"

Erwartet: 18 main 5432 online; enabled; 127.0.0.1:5432 und [::1]:5432 mit Prozess postgres; PostGIS-Version mit PROJ="9.x". Nach wsl --shutdown und erneutem Öffnen muss der Cluster wieder online sein, sonst läuft systemd nicht (W2).

W4: Import und Dateien in WSL

Schritt 4–12 (Ubuntu) unverändert in der WSL-Shell. Windows-spezifisch ist nur der Umgang mit Dateien:

  • Das Projekt gehört ins Linux-Dateisystem (~/haltestellen); unter /mnt/c/… ist jeder Zugriff langsam. Im Explorer erreicht man den Ordner über \\wsl.localhost\Ubuntu-24.04\home\<linux-user>\haltestellen; explorer.exe . in WSL öffnet das aktuelle Verzeichnis.
  • Zeilenenden: Mit einem Windows-Editor angelegte Skripte haben CRLF-Zeilenenden und brechen mit $'\r': command not found ab. Abhilfe: sudo apt install -y dos2unix, dos2unix *.sh *.sql – oder VS Code mit der Erweiterung „WSL“ (code . in WSL).

✅ Test:

# in WSL (Ubuntu)
cd ~/haltestellen && pwd
file hs_import.sh 01_staging.sql 02_haltestellen.sql
./hs_import.sh

Erwartet: /home/<linux-user>/haltestellen; file meldet kein CRLF; das Skript endet mit 10428 | 3647 | 4326.

W5: Zugriff von Windows – pgAdmin, QGIS, listen_addresses, pg_hba.conf

pgAdmin oder QGIS unter Windows verbinden sich von außerhalb der VM. Drei Ebenen greifen ineinander: das Netzwerk Windows ↔ WSL, listen_addresses (auf welchen Adressen nimmt PostgreSQL Verbindungen an) und pg_hba.conf (wer darf sich wie anmelden).

a) Der Normalfall: 127.0.0.1

FeldWertHinweis
Host127.0.0.1Nicht localhost: Windows löst das oft zu ::1 auf, und IPv6-Loopback wird im Mirrored-Modus nicht unterstützt
Port5432
Datenbankhs
BenutzergisuserNicht postgres: der Superuser hat unter Ubuntu kein Passwort → password authentication failed
Passwortgis123

Das braucht keine Änderung an PostgreSQL: Die Verbindung kommt von 127.0.0.1, und dafür steht in der Standard-pg_hba.conf schon host all all 127.0.0.1/32 scram-sha-256.

✅ Test:

# PowerShell
Test-NetConnection 127.0.0.1 -Port 5432
Get-NetTCPConnection -LocalPort 5432 -State Listen -ErrorAction SilentlyContinue |
  Select-Object LocalAddress, OwningProcess,
    @{n='Prozess'; e={(Get-Process -Id $_.OwningProcess).ProcessName}}
-- im Query Tool von pgAdmin bzw. in der DB-Verwaltung von QGIS
SELECT version(), postgis_version(), inet_client_addr(), current_user;

Erwartet: TcpTestSucceeded : True. Erscheint ein Prozess postgres, ist das ein Windows-PostgreSQL, das „gewinnt“. Die Abfrage zeigt … on x86_64-pc-linux-gnu (Linux; ein Windows-PostgreSQL meldet compiled by Visual C++), 127.0.0.1 und gisuser.

b) Nur falls nötig: über die WSL-IP (NAT-Modus)

Klappt 127.0.0.1 im NAT-Modus nicht (z. B. VPN), verbindet man über die IP der VM (wsl hostname -I in PowerShell). Dafür zwei gezielte Änderungen:

# in WSL (Ubuntu)
# 1) postgresql.conf: auf allen Adressen lauschen – Plural: listen_addresses
sudo sed -i "s/^#\?listen_addresses.*/listen_addresses = '*'/" /etc/postgresql/18/main/postgresql.conf

# 2) pg_hba.conf: nur unsere DB, unsere Rolle, das private WSL-Netz, mit Passwort
echo "host    hs    gisuser    172.16.0.0/12    scram-sha-256" | sudo tee -a /etc/postgresql/18/main/pg_hba.conf

sudo systemctl restart postgresql   # listen_addresses braucht einen Neustart

Nie trust als Methode, nie 0.0.0.0/0 als Adresse. listen_addresses = '*' nur im NAT-Modus; im Mirrored-Modus hat WSL die echten Netzwerkadressen des Rechners und wäre aus dem LAN erreichbar.

✅ Test:

# in WSL (Ubuntu)
pg_lsclusters
sudo ss -ltnp | grep 5432
sudo -u postgres psql -c "SELECT line_number, type, database, user_name, address, auth_method, error FROM pg_hba_file_rules;"

Erwartet: Cluster online (bei down: /var/log/postgresql/postgresql-18-main.log, oft ein Tippfehler wie listen_address); 0.0.0.0:5432; die neue Zeile host | {hs} | {gisuser} | 172.16.0.0 | scram-sha-256 mit leerer Spalte error.

SymptomUrsacheLösung
connection refused auf localhost:5432WSL-VM läuft nicht, oder localhost → ::1Ubuntu-Fenster öffnen; 127.0.0.1 verwenden
role "gisuser" does not existWindows-PostgreSQL auf Port 5432 „gewinnt“Get-Service *postgres*, Dienst stoppen
password authentication failed for user "postgres"postgres hat kein PasswortAls gisuser verbinden
In WSL: Peer authentication failedpsql ohne Host → Unix-Socket → peerpsql -h localhost -U gisuser -d hs bzw. die URL
no pg_hba.conf entry for host "172.…"Verbindung über WSL-IP ohne passende RegelZeile aus b) ergänzen, systemctl reload postgresql
type "geometry" does not existPostGIS in dieser Datenbank nicht aktiviertAls Superuser: \c hs, CREATE EXTENSION postgis;
$'\r': command not foundWindows-Zeilenendendos2unix hs_import.sh

Aufräumen

Als Superuser (Ubuntu: sudo -u postgres psql, macOS: psql postgres):

DROP DATABASE IF EXISTS hs;
DROP ROLE IF EXISTS gisuser;

Quellen