Back to feed

Project docs

Ski Jumping Statistics

PostgreSQL analysis of men's Ski Jumping World Cup results, supported by a Python data collection pipeline.

Competition Results

The final classification of a competition, including each athlete’s combined points, finishing position, and awarded World Cup points.

Rules

  • Points scored across all rounds are summed to produce each athlete’s final result.
  • Athletes are ranked by their combined points.
  • World Cup points are awarded to the top 30 finishers and later contribute to the overall season standings.
  • Athletes outside the top 30 receive zero World Cup points.

World Cup scoring system

  • 1st → 100
  • 2nd → 80
  • 3rd → 60
  • 4th → 50
  • 5th → 45
  • 6th → 40
  • 7th → 36
  • 8th → 32
  • 9th → 29
  • 10th → 26
  • 11th–30th → decreasing from 24 to 1

Reusable competition results

Competition results form the foundation of many later statistics. To make them faster and easier to reuse, the aggregated data is stored in a materialized view.

create materialized view competition_results
as (
	with competition_total_points as (
		select r.race_id, r.jumper_id, SUM(r.total_points) as total_points
		from results r
		group by r.race_id, r.jumper_id
	),
	results_ranked as (
		select *,
			rank() over (partition by race_id order by total_points desc) as pos
		from competition_total_points
	),
	competition_scoring as (
	    SELECT * FROM (VALUES
	        (1, 100), (2, 80), (3, 60), (4, 50), (5, 45),
	        (6, 40),  (7, 36), (8, 32), (9, 29), (10, 26),
	        (11, 24), (12, 22), (13, 20), (14, 18), (15, 16),
	        (16, 15), (17, 14), (18, 13), (19, 12), (20, 11),
	        (21, 10), (22, 9),  (23, 8),  (24, 7),  (25, 6),
	        (26, 5),  (27, 4),  (28, 3),  (29, 2),  (30, 1)
	    ) AS t(pos, score)
	)
	select r.*, coalesce(cs.score, 0) as score
	from results_ranked r
	left join competition_scoring cs on r.pos = cs.pos
	order by race_id, total_points desc
)
with data;

View presentation

First 20 competition_results view results20 rows
race_idjumper_idtotal_pointsposscore
4916122301.400021100
491681298.6280
491672293360
491668292.7450
491678284.3545
491667284640
491676283.09998736
491673282832
491674281.09998929
491664280.71026
491670279.399961124
4916792791222
491666276.71320
491675269.51418
4916186267.61516
491682265.21615
4916111261.31714
491680260.800021813
491697258.31912
491665258.12011

Refreshing the view

PostgreSQL does not refresh materialized views automatically when their source data changes. A statement-level trigger therefore refreshes competition_results after new rows are inserted into the results table.

create function refresh_competition_results()
	returns trigger
	language plpgsql
as $$
begin
	refresh materialized view competition_results;
	return null;
end $$;

create trigger results_insert
	after insert
	on results
	for each statement
	execute function refresh_competition_results();

Refreshing a materialized view through a trigger would often be too expensive for a frequently updated table. Here, however, results change only when the web scraping pipeline loads new competitions, making the trade-off acceptable for this project.

Presenting a competition

The materialized view can now be combined with athlete data and find_race_id to present the competition held in Bischofshofen, Austria, in 2025:

select
	cr.pos,
	j.jumper_name || ' (' || j.jumper_country || ')' as jumper,
	cr.total_points::decimal(10, 2),
	cr.score
from competition_results cr
join jumpers j using (jumper_id)
where race_id = find_race_id(2025, 'Bischofshofen', 1);

Results

Competition held in Bischofshofen, Austria, in 202550 rows
posjumpertotal_pointsscore
1TSCHOFENIG Daniel (AUT)308.60100
2HOERL Jan (AUT)306.5080
3KRAFT Stefan (AUT)303.2060
4FORFANG Johann Andre (NOR)301.7050
5ORTNER Maximilian (AUT)300.0045
6OESTVOLD Benjamin (NOR)298.2040
7HAYBOECK Michael (AUT)296.9036
8WASEK Pawel (POL)294.1032
9WELLINGER Andreas (GER)290.9029
10NIKAIDO Ren (JPN)289.9026
11DESCHWANDEN Gregor (SUI)288.6024
12PASCHKE Pius (GER)286.5022
13FRANTZ Tate (USA)282.6020
14BICKNER Kevin (USA)281.2018
15RAIMUND Philipp (GER)280.9016
16SUNDAL Kristoffer Eriksen (NOR)278.8015
17MUELLER Markus (AUT)276.2014
18LANISEK Anze (SLO)275.1013
19PREVC Domen (SLO)274.2012
20FETTNER Manuel (AUT)272.6011
21GRANERUD Halvor Egner (NOR)271.5010
22EMBACHER Stephan (AUT)271.109
23GEIGER Karl (GER)270.908
24KOBAYASHI Ryoyu (JPN)270.407
25PEIER Killian (SUI)267.006
26ZAJC Timi (SLO)266.505
27ZOGRAFSKI Vladimir (BUL)262.204
28KUBACKI Dawid (POL)261.603
29ZNISZCZOL Aleksander (POL)258.302
30LINDVIK Marius (NOR)233.201
31NAKAMURA Naoki (JPN)134.200
32VILLUMSTAD Fredrik (NOR)130.700
33TITTEL Adrian (GER)125.900
34FOUBERT Valentin (FRA)125.000
35AIGRO Artti (EST)124.600
36HOFFMANN Felix (GER)124.100
36KOUDELKA Roman (CZE)124.100
38AIGNER Clemens (AUT)124.000
39JELAR Ziga (SLO)123.600
40SATO Keiichi (JPN)121.900
41NAITO Tomofumi (JPN)120.400
42KOBAYASHI Sakutaro (JPN)119.500
43SCHUSTER Jonas (AUT)118.900
44LANDERER Hannes (AUT)117.700
45KOS Lovro (SLO)117.100
46INSAM Alex (ITA)116.000
47IPCIOGLU Fatih Arda (TUR)115.500
48ZYLA Piotr (POL)108.600
49URLAUB Andrew (USA)86.500
50KYTOSAHO Niko (FIN)80.400