SELECT p.first_name
, p.last_name
, year
, pas.passing_yards
, rus.rushing_yards
, rec.receiving_yards
FROM players p
LEFT JOIN (
(SELECT * FROM passing_stats WHERE id = 100) pas
FULL JOIN (SELECT * FROM rushing_stats WHERE id = 100) rus USING (id, year)
FULL JOIN (SELECT * FROM receiving_stats WHERE id = 100) rec USING (id, year)
) USING (id)
WHERE p.id = 100
ORDER BY year;
The subqueries are the core feature - to reduce to relevant rows early which should yield the best performance.
@ypercube's idea with NATURAL JOIN is even a bit shorter but breaks more easily when the query or the underlying tables are changed. Some even frown at the USING clause for similar reasons, to a lesser extent. But it keeps the query short and easy to read in this case.
The LEFT JOIN to return players with no statistics at all.
Alternate query
My first idea was basically a variant of @kgrittn's query. My fixes are somewhat invasive, so I posted another answer. Requires PostgreSQL 8.4 or newer.
WITH x AS (SELECT 100 AS id)
, y AS (
SELECT year FROM x JOIN passing_stats USING (id)
UNION
SELECT year FROM x JOIN rushing_stats USING (id)
UNION
SELECT year FROM x JOIN receiving_stats USING (id)
)
SELECT p.first_name
, p.last_name
, y.year
, s.passing_yards
, r.rushing_yards
, c.receiving_yards
FROM (x CROSS JOIN y)
JOIN players p USING (id)
LEFT JOIN passing_stats s USING (id, year)
LEFT JOIN rushing_stats r USING (id, year)
LEFT JOIN receiving_stats c USING (id, year)
ORDER BY y.year;
db<>fiddle here