From JPL Horizons to SQL
JPL Horizons is the gold standard for solar system ephemeris data. Run by the Solar System Dynamics group at the Jet Propulsion Laboratory, it serves precise positions for every known body — planets, moons, asteroids, comets, spacecraft. You can access it through a web interface, telnet, email, or REST API.
pg_orbit does not replace Horizons. What it does is move the 95% of queries that don’t need sub-milliarcsecond precision from a remote API into your local database — with no rate limits, no network latency, and results that join directly with your other tables.
Planet ephemeris query
Section titled “Planet ephemeris query”The most common Horizons request: “Where is Mars from my location at this time?”
import requests
params = { 'format': 'json', 'COMMAND': '499', # Mars 'OBJ_DATA': 'NO', 'MAKE_EPHEM': 'YES', 'EPHEM_TYPE': 'OBSERVER', 'CENTER': 'coord@399', 'COORD_TYPE': 'GEODETIC', 'SITE_COORD': '-105.3,40.0,1.655', # lon, lat, alt(km) 'START_TIME': '2025-06-15 00:00', 'STOP_TIME': '2025-06-15 00:01', 'STEP_SIZE': '1', 'QUANTITIES': '1,4,20', # Astrometric RA/Dec, Az/El, Range}
response = requests.get( 'https://ssd.jpl.nasa.gov/api/horizons.api', params=params)
data = response.json()# Parse the text block in data['result']# Horizons returns fixed-width text, not structured JSONprint(data['result'])The API returns a text block with column headers embedded in the response body.
Parsing it requires knowing the column positions or using a library like
astroquery.jplhorizons. The response format varies depending on which
quantities you request.
Rate limits: JPL asks for no more than ~200 heavy queries per hour from a single IP. Automated batch jobs that generate thousands of queries risk being throttled or blocked.
SELECT topo_azimuth(t) AS az, topo_elevation(t) AS el, topo_range(t) AS range_kmFROM planet_observe(4, '40.0N 105.3W 1655m'::observer, '2025-06-15 00:00:00+00'::timestamptz) t;Same computation. No network call, no parsing, no rate limits.
The result is a typed topocentric value — access individual components
with topo_azimuth(), topo_elevation(), topo_range(), topo_range_rate().
Batch queries over time ranges
Section titled “Batch queries over time ranges”This is where the workflow difference becomes dramatic. Generating a 24-hour elevation profile at 10-minute resolution means 144 data points.
import requests
# Option A: Single request with STEP_SIZEparams = { 'format': 'json', 'COMMAND': '599', # Jupiter 'MAKE_EPHEM': 'YES', 'EPHEM_TYPE': 'OBSERVER', 'CENTER': 'coord@399', 'COORD_TYPE': 'GEODETIC', 'SITE_COORD': '-105.3,40.0,1.655', 'START_TIME': '2025-06-15 00:00', 'STOP_TIME': '2025-06-16 00:00', 'STEP_SIZE': '10m', # 10-minute intervals 'QUANTITIES': '4', # Az/El only}
response = requests.get( 'https://ssd.jpl.nasa.gov/api/horizons.api', params=params)
# Parse 144 lines of fixed-width text# Extract azimuth and elevation from each linelines = response.json()['result'].split('\n')# ... parsing logic ...For a single body and time range, Horizons handles this in one request. But what if you want this for all 8 planets? That’s 8 API calls. For 5 observers? That’s 40. For a full year at 1-hour resolution? You’re managing thousands of requests, rate limiting, error handling, and stitching results together.
-- Jupiter elevation over 24 hours, 10-minute stepsSELECT t, topo_azimuth(obs) AS az, topo_elevation(obs) AS elFROM generate_series( '2025-06-15 00:00:00+00'::timestamptz, '2025-06-16 00:00:00+00'::timestamptz, interval '10 minutes' ) AS t,LATERAL planet_observe(5, '40.0N 105.3W 1655m'::observer, t) AS obs;-- All 8 planets, 5 observers, full year, 1-hour resolution-- = 8 * 5 * 8760 = 350,400 observationsSELECT body_id, obs_name, t, topo_elevation(planet_observe(body_id, location, t)) AS elFROM generate_series(1, 8) AS body_id, (VALUES ('Boulder', '40.0N 105.3W 1655m'::observer), ('London', '51.5N 0.1W 11m'::observer), ('Tokyo', '35.7N 139.7E 40m'::observer), ('Sydney', '33.9S 151.2E 58m'::observer), ('Nairobi', '1.3S 36.8E 1795m'::observer) ) AS observers(obs_name, location), generate_series( '2025-01-01'::timestamptz, '2025-12-31'::timestamptz, interval '1 hour' ) AS t;350,400 observations. One query. No rate limits. Results land in a table you can index, aggregate, and join.
Moon positions
Section titled “Moon positions”Horizons excels at moons — it has ephemerides for every known natural satellite. pg_orbit covers the 19 most-observed moons.
import requests
# Galilean moons: Io=501, Europa=502, Ganymede=503, Callisto=504moons = {'Io': '501', 'Europa': '502', 'Ganymede': '503', 'Callisto': '504'}
for name, code in moons.items(): params = { 'format': 'json', 'COMMAND': code, 'MAKE_EPHEM': 'YES', 'EPHEM_TYPE': 'OBSERVER', 'CENTER': 'coord@399', 'COORD_TYPE': 'GEODETIC', 'SITE_COORD': '-105.3,40.0,1.655', 'START_TIME': '2025-06-15 03:00', 'STOP_TIME': '2025-06-15 03:01', 'STEP_SIZE': '1', 'QUANTITIES': '1,4,20', } response = requests.get( 'https://ssd.jpl.nasa.gov/api/horizons.api', params=params ) # Parse each response separately...Four separate API calls. To track all four moons over a night of observation at 5-minute intervals (say, 8 hours = 96 steps), that’s 4 requests or careful batching.
-- All four Galilean moons, right nowSELECT moon_id, CASE moon_id WHEN 0 THEN 'Io' WHEN 1 THEN 'Europa' WHEN 2 THEN 'Ganymede' WHEN 3 THEN 'Callisto' END AS name, topo_azimuth(galilean_observe(moon_id, obs, now())) AS az, topo_elevation(galilean_observe(moon_id, obs, now())) AS el, topo_range(galilean_observe(moon_id, obs, now())) AS range_kmFROM generate_series(0, 3) AS moon_id, (VALUES ('40.0N 105.3W 1655m'::observer)) AS o(obs);-- Track all four moons over an 8-hour observation sessionSELECT t, moon_id, topo_elevation(galilean_observe(moon_id, obs, t)) AS elFROM generate_series(0, 3) AS moon_id, generate_series( '2025-06-15 02:00:00+00'::timestamptz, '2025-06-15 10:00:00+00'::timestamptz, interval '5 minutes' ) AS t, (VALUES ('40.0N 105.3W 1655m'::observer)) AS o(obs);384 observations (4 moons times 96 timestamps). One query.
Lambert transfer survey
Section titled “Lambert transfer survey”This is where the difference is most striking. Horizons doesn’t compute transfer orbits directly — you’d use its ephemeris data as input to your own Lambert solver. pg_orbit does both in one step.
from astroquery.jplhorizons import Horizonsfrom poliastro.iod import izzofrom astropy import units as uimport numpy as np
# Step 1: Get Earth and Mars positions from Horizons# for each departure/arrival date pairdep_dates = pd.date_range('2028-08-01', '2028-12-01', freq='5D')arr_dates = pd.date_range('2029-04-01', '2029-09-01', freq='5D')
results = []for dep in dep_dates: # Query Earth heliocentric state at departure earth = Horizons(id='399', location='@sun', epochs=dep.jd) earth_vec = earth.vectors() # API call
for arr in arr_dates: # Query Mars heliocentric state at arrival mars = Horizons(id='499', location='@sun', epochs=arr.jd) mars_vec = mars.vectors() # API call
# Solve Lambert problem r1 = [earth_vec['x'][0], earth_vec['y'][0], earth_vec['z'][0]] * u.AU r2 = [mars_vec['x'][0], mars_vec['y'][0], mars_vec['z'][0]] * u.AU tof = (arr - dep).days * u.day
try: (v1, v2), = izzo.lambert(Sun.k, r1, r2, tof) c3 = (np.linalg.norm(v1.value) ** 2) results.append({'dep': dep, 'arr': arr, 'c3': c3}) except: pass
# For a 25x31 grid, that's 775 departure queries + 775 arrival queries# to Horizons, plus 775 Lambert solves in PythonThe Horizons queries alone — even with careful batching — take minutes and risk rate limiting. The Lambert solve is the easy part.
-- Full pork chop plot: 25 departure dates x 31 arrival dates = 775 transfersSELECT dep::date AS departure, arr::date AS arrival, round(c3_departure::numeric, 2) AS c3_km2s2, round(tof_days::numeric, 0) AS flight_daysFROM generate_series( '2028-08-01'::timestamptz, '2028-12-01'::timestamptz, interval '5 days' ) AS dep, generate_series( '2029-04-01'::timestamptz, '2029-09-01'::timestamptz, interval '5 days' ) AS arr,LATERAL lambert_transfer(3, 4, dep, arr) AS xferWHERE tof_days > 90; -- Filter unrealistic short transferspg_orbit computes the planet positions AND solves Lambert internally. No external API calls. The 775 transfer solutions run in under a second.
Scale it up to a 150x150 grid (22,500 solutions) and it finishes in about 8.3 seconds.
Where Horizons wins
Section titled “Where Horizons wins”Accuracy. DE441 provides sub-milliarcsecond planetary positions. pg_orbit’s VSOP87 is accurate to about 1 arcsecond — a factor of 1000 less precise. For spacecraft navigation, radar astrometry, or occultation timing, Horizons is the correct source.
Aberration corrections. Horizons applies light-time iteration, stellar aberration, and gravitational deflection of light. pg_orbit uses geometric positions only.
Physical properties. Horizons can return visual magnitude, angular diameter, phase angle, illuminated fraction, and surface brightness. pg_orbit returns geometric position and range.
Topographic corrections. Horizons accounts for Earth’s oblateness and topographic features at the observer’s location using precise geodetic models. pg_orbit uses a WGS84 ellipsoid.
Body catalog. Horizons knows about every numbered asteroid, every known comet, and spacecraft past and present. pg_orbit covers the 8 planets, the Sun, the Moon, 19 planetary moons, and whatever comets/asteroids you define with Keplerian elements.
Where pg_orbit wins
Section titled “Where pg_orbit wins”No network dependency. pg_orbit runs locally, in your database process. No DNS resolution, no TLS handshake, no API parsing. Useful in air-gapped environments, on aircraft, or when Horizons is down for maintenance.
No rate limits. Horizons is generous but not unlimited. Automated pipelines that generate thousands of queries — pork chop plot surveys, Monte Carlo trajectory analysis, multi-body scheduling — can hit throttling. pg_orbit has no external limits; you’re bounded only by your own hardware.
Batch everything locally. The Lambert transfer example above illustrates this best. What takes hundreds of API calls and minutes of wall-clock time in the Horizons workflow is a single query that runs in seconds.
Results in your database. Horizons returns text that you parse and then insert. pg_orbit results are already rows in PostgreSQL — ready to JOIN, index, aggregate, or export.
Reproducibility. A pg_orbit query is deterministic. Given the same inputs, it produces the same output on any PostgreSQL instance with the extension installed. No dependency on the current state of a remote API or the version of its ephemeris files.
A practical workflow
Section titled “A practical workflow”For many projects, the right approach uses both.
-
Use Horizons for calibration. Run the same computation in both systems and compare. pg_orbit should agree with Horizons to within about 1 arcsecond for planets and a few arcseconds for moons. If the difference matters for your application, use Horizons.
-
Use pg_orbit for surveys. Any time you need positions for many bodies, many timestamps, or many observers — parameter sweeps, scheduling optimization, catalog screening — run it locally.
-
Use pg_orbit for integration. When orbital data needs to join with other database tables — observation logs, equipment schedules, frequency allocations — computing inside PostgreSQL eliminates the ETL step.
-
Use Horizons for exotic bodies. If you need positions for Pluto, numbered asteroids with precise osculating elements, or decommissioned spacecraft, Horizons is the only option.