
Chris Parmer
October 27, 2026
3D Surface Plots - A Complete Guide
TL;DR
- A 3D surface plot draws a value measured over a grid of two variables (z over x and y) as a sheet, so the shape of the whole grid is visible at once: ridges, valleys, slopes and cliffs.
- 3D surface plots are also known as surface plots, surface charts and 3-D surface charts, and are similar to contour plots and heatmaps.
- Use one when the shape is the point: terrain, volatility surfaces, yield curves over time, dose-response matrices, power output by hour and day.
- 3D hides whatever sits behind a peak and makes exact values hard to read, so choose the camera on purpose, add contour lines, and pair the surface with a heatmap or line chart when readers need numbers. Interactive HTML, where readers can rotate it, is where a surface works best.
- Any coding agent can make one with Plotly in a minute. In Python it's go.Figure(go.Surface(z=grid)).
What is a 3D surface plot?
3D surface plots look like this:

Data: California ISO, Today's Outlook history.
How do you read a 3D surface plot? In the example above, notice these things:
- The two flat axes are the grid the data lives on, with the hour of day running left to right and the year running from the oldest at the back to the newest at the front. Each thin black line across the surface is one year's average April day on the California grid.
- The height of the surface is net demand, the power other plants still had to supply after wind and solar. Color repeats the height, so the orange ridges are the night and evening hours and the pale middle of the day is where wind and solar cover more than half of the load.
- Reading from back to front shows the change over time. The midday valley gets deeper every year, which is the "duck curve" growing as wind and solar cover a larger share of the midday demand each year.
- The blue patch at the front of the valley is where net demand went below zero, meaning wind and solar produced more than all of the demand. It first shows up at midday in 2025.
- Watch out for perspective, because a 3D view makes rows near the front look bigger and can hide low points behind high ones. Use the colorbar, or hover over the surface in an interactive version, for exact values such as the 2026 evening ramp that the subtitle puts at 22.9 GW.
It's often used for charts like these:

An implied volatility surface. One-month S&P 500 puts 20% below the index traded at 41% implied volatility on 23 October 2026, 2.7 times the 15% at the money. Data: Cboe delayed quotes for SPX options.

Terrain. Mount St. Helens is 401 m lower than before its 1980 eruption (USGS), and its horseshoe crater opens to the north. Data: USGS 3D Elevation Program.

A dose-response surface. At the highest doses, bortezomib plus clofarabine brought average cancer cell growth to -54% (cells killed), about twice the -26% expected if the two drugs acted independently. Data: NCI-ALMANAC (Holbeck et al., 2017).
A short history
In 1879 the Italian statistician Luigi Perozzo drew Sweden's census population for 1750 to 1875 as a 3D surface of age by year, one of the best-known early stereograms of statistical data (Friendly and Denis, Milestones). Here is his layout redrawn with Plotly on Statistics Sweden's single-age series, which starts in 1860:

Depth has a mixed record since then. When Michael Siegrist tested pie and bar charts with and without it, readers judged 3D pie charts less accurately and took longer on 3D bar charts (Siegrist, 1996), so use 3D for grids that really have two dimensions, never to decorate a flat chart.
Alternatives to a 3D surface plot
Here's the same duck curve drawn four ways. Some are better for the question, some worse, some just different:

- Heatmap (better for reading values). Every cell is visible and nothing hides behind a peak, though color is less precise than height or position. See our heatmap guide.
- Contour plot (works too). Lines of equal value show where a level is crossed, the way topographic maps show terrain, and you can label them. See our contour plot guide.
- Line chart (better for a few rows). One line per year gives exact values and makes the 2025-2026 drop below zero obvious. Past about ten lines it gets crowded.
- 3D surface (different). It shows the overall shape fastest and is the most engaging, especially when readers can rotate it, but it's the hardest to read precisely.
How to make a 3D surface plot
With AI
ChatGPT, Claude Code, GitHub Copilot, Cursor and other AI tools are excellent at writing Plotly code. Name Plotly in the prompt, say what's on each axis and what the color means, and list the checks you want:
Make a plotly 3D surface from yields.csv (columns: date, tenor_years, yield_pct; one row per date and tenor).- Pivot to a grid: rows = date, columns = tenor_years. Keep missing cells as NaN, don't fill them.- go.Surface with time on x and tenor on y; color the surface by the 10-year minus 3-month spreadthrough surfacecolor, on a red-gray-blue scale centered on zero.- aspectmode manual, aspectratio x=2.2, y=0.8, z=0.9. Try three camera eyes and keep the onewhere nothing important is hidden.- Soft lighting (specular 0.05) and a title that states the takeaway with a number.- Also make a line chart of the two tenors and their spread.- Save both as HTML and as PNG at scale=2, then check the PNG for hidden or clipped labels.
With Plotly Studio
Plotly Studio is a desktop app where you point an AI at a dataset and it builds charts and a Dash app around your questions. Ask for a surface the same way:
Show a 3D surface of hourly solar output by hour of day and day of the year, with a white-to-orange colorscale where zero is white, viewed from the morning side, and a heatmap of the same data next to it.
Make a 3D surface plot in Python or JavaScript
This is the code AI tools write under the hood. In Python, go.Surface needs only a 2D array; this is Mount Bruno's elevation grid from Plotly's documentation:
import pandas as pdimport plotly.graph_objects as goz = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/master/api_docs/mt_bruno_elevation.csv", index_col=0)fig = go.Figure(go.Surface(z=z.values, colorscale="Viridis",contours=dict(z=dict(show=True, project_z=True, usecolormap=True))))fig.update_layout(scene_camera=dict(eye=dict(x=1.6, y=-1.4, z=0.8)))fig.show()
If your data is in long format, one row per measurement, pivot it first with df.pivot(index="y", columns="x", values="z") and pass the column and index values as x and y.
In JavaScript, plotly.js takes z as an array of rows, one per y value. These are California's April net demand numbers from the chart at the top:
<div id="chart"></div><script src="https://cdn.plot.ly/plotly-4.1.1.min.js"></script><script>Plotly.newPlot("chart", [{type: "surface",x: [0, 6, 12, 18, 23], // hour of dayy: [2019, 2022, 2025, 2026], // April of each yearz: [[18.3, 19.7, 10.7, 20.1, 19.6],[17.8, 18.3, 4.7, 17.0, 18.8],[18.0, 18.6, -0.8, 15.4, 18.8],[17.7, 17.8, -1.4, 14.2, 18.2]],contours: {z: {show: true, start: 0, end: 20, size: 4}}}], {scene: {zaxis: {title: {text: "GW"}}}});</script>
Share it
Every Plotly chart has a Share chart button (the cloud icon in its toolbar) that uploads it to Plotly Cloud and gives you a link. Only you can see it until you change its visibility. For a 3D surface this is the best way to share it, because readers can rotate it and hover for values.
For a live app, put the chart in a Dash app and publish it with the Plotly Cloud CLI. Coding agents can run these commands for you:
pip install "dash[cloud]"plotly user loginplotly app publish
If your data has to stay in your own cloud or data center, publish to Dash Enterprise instead.
Dos and don'ts
Plotly's defaults are made for exploring, and a surface on its own looks fine until you ask what a reader can actually see. Here is the same Mount St. Helens elevation grid, in the same colors, both ways.
Don't leave the box as a cube, which stretches this mountain about 4.5 times without saying so, label the axes with grid indexes, skip contour lines, or look from the side where the crater is hidden:

Do use real units with true proportions and state any exaggeration, add contour lines, light it softly, and point the camera at the feature that matters:

Choose the camera on purpose
In a static image the camera decides what is in front and what is hidden. Here are four cameras on the duck curve.
Plotly's default angle. It's a reasonable start, but here it looks from the late-evening corner, so time runs away from the reader and the evening ramp overlaps the midday dip.

From 2019 (avoid). The oldest and highest curve is in front, so it hides the deeper recent years.

From 2026, with the x axis reversed (best here). The deepest curve is in front, every year is visible, and the day still runs left to right.

From straight above. The surface becomes a heatmap with contour lines, so nothing is hidden but the shape is gone.

Checklist
Do
- Write a title that states the takeaway with a number.
- Try at least three cameras with scene.camera.eye and keep the one that hides nothing important.
- Set aspectmode="manual" and aspectratio, and state any vertical exaggeration.
- Add contour lines with contours.z, and use surfacecolor when a second variable deserves the color.
- Keep missing cells as NaN so they show as holes, and mark measured points when the surface is interpolated.
- Publish the interactive HTML too, and pair the surface with a heatmap or line chart when readers need numbers.
Don't
- Use a surface when either axis is a category, or for a handful of curves.
- Use a rainbow colorscale such as Jet, which creates false boundaries.
- Trust the default cube, which stretches flat data and squashes tall data.
- Let a peak hide the part of the data that matters.
Options to customize
These are the go.Surface options, and the 3D scene settings around it, that matter most. The full list is in the Plotly 3D surface documentation.
- z: a 2D array of values, one row per y and one column per x.
- x, y: the coordinates of the columns and rows; 2D arrays make a parametric surface such as a polar grid.
- surfacecolor: a second 2D array that sets the color instead of z, for a second variable.
- colorscale, cmin, cmax, cmid: the color mapping; cmid=0 for signed data, or set cmin and cmax to put a color break at sea level.
- contours.x, contours.y, contours.z: lines of equal value (show, start, end, size, color, width).
- contours.z.project.z, usecolormap: copy the contours onto the floor of the box.
- contours.z.highlight: lines that follow the mouse in the interactive version.
- hidesurface: draw only the contour lines, for a wireframe look.
- opacity: transparency, for a second, reference surface.
- lighting: ambient, diffuse, specular, roughness and fresnel; low specular and high roughness avoid a plastic look.
- lightposition: where the light comes from, such as the northwest for terrain.
- hovertemplate, customdata: hover text with units, labels and counts.
- scene.camera.eye, center, up: the camera position and target.
- scene.camera.projection.type: orthographic removes perspective, so equal heights look equal.
- scene.aspectmode, aspectratio: the box proportions.
- scene.xaxis.type (and y, z): linear, log, date or category; a log z axis suits rates.
- scene.xaxis.autorange="reversed": flip an axis to keep the reading direction when the camera turns around.
- scene.annotations: text with arrows anchored to 3D points.
- go.Mesh3d (x, y, z, i, j, k, delaunayaxis, alphahull, intensity): a surface from triangles or scattered points, for data that isn't on a grid.
More 3D surface plot examples
Ten more surfaces built from real public data, each with its source, the technique that makes it work and a prompt to rebuild it.
1. The US Treasury yield curve since 1982

Each of the 537 months is one yield curve from 3 months to 10 years, and side by side they show the 40-year fall in rates and the jump in 2022. The 3-month yield sat above the 10-year for 25 months in a row, from November 2022 to November 2024, the longest run since at least 1982. Data: Federal Reserve H.15 via FRED, monthly averages.
Tip: the color comes from surfacecolor set to the 10-year minus 3-month spread, so every inverted month is a red band whatever its height.
Get the data from FRED's monthly constant-maturity Treasury yields (series GS3M, GS6M, GS1,GS2, GS3, GS5, GS7, GS10, monthly averages of the Federal Reserve H.15 release)and shape it into a table(columns: month, maturity, maturity_years, yield_pct; January 1982 to the latest month).Build a plotly go.Surface of the yield curve over time.- Pivot to a grid: x = month as decimal years, y = maturity as evenly spaced positions labeled3M ... 10Y, z = yield_pct.- surfacecolor = 10Y minus 3M for that month (the same value across the row), red-gray-bluescale from -3 to +3, so inverted months are red.- aspectratio x=2.4, y=0.8, z=1.0; camera from the front-left (eye x=-0.6, y=-2.35, z=1.1).- Title with the longest run of inverted months; muted subtitle with the source.
Another way to see it: two lines and their difference read more precisely than the surface. The spread fell to -1.74 points in May 2023, a number the surface can't give you.

2. California's solar output, hour by hour, for a year

Every day of 2025 is one bell curve of solar output, and together they form a ridge that is wide in summer and narrow in winter. The grid's solar peaked at 21.6 GW at noon on 30 July, and the sunniest day (16 June, 234 GWh) made 15 times the energy of the darkest (31 December, 16 GWh). Data: California ISO, Today's Outlook history.
Tip: the camera looks from the morning side with a light from the same direction (lightposition), so the notches left by storms are shaded.
Get the data from the California ISO's Today's Outlook history (daily renewables file, 5-minutesolar output in MW, for every day of 2025)and shape it into a table(columns: date, hour, solar_mw = hourly mean of the 5-minute values, negatives set to 0).Build a plotly go.Surface with hour of day on x, day of year on y (ticks at the first of eachmonth) and GW on z. White-to-yellow-to-orange colorscale with zero as white.Camera from the morning side (eye x=-1.75, y=-1.05, z=0.9), lightposition from the same side.Fill the missing spring-forward 2 a.m. with 0. Title with the peak and the best/worst day ratio.
3. Where a wind farm's energy comes from

This is a wind rose with height: each cell is the energy the six Kelmarsh turbines produced at that wind speed and direction in 2017. Winds from 188 to 263 degrees, a fifth of the compass, produced 53% of the 32.3 GWh. Data: Plumley (2022), Kelmarsh wind farm data, Zenodo, 10-minute SCADA records.
Tip: a parametric surface, where x, y and z are all 2D arrays (x = speed × sin(direction), y = speed × cos(direction)), draws a polar grid.
Get the data from the Kelmarsh wind farm data on Zenodo (doi 10.5281/zenodo.5841834,2017 SCADA file, six Senvion MM92 turbines, 10-minute rows)and shape it into a table(columns: speed_ms bin center (0.5 m/s bins, 2-16 m/s), direction_deg bin center (15-degreebins centered on 0..345), energy_mwh = power x 1/6 h summed over turbines, turbine_hours).Drop rows where power <= 0 above 4 m/s (turbine not running).Build a plotly parametric go.Surface: x = speed*sin(direction), y = speed*cos(direction),z = energy, with north up. Close the circle by repeating the first direction, start at radius 0.Hide the axes; draw speed rings and N/E/S/W as Scatter3d lines and text on the floor.Title with the share of energy from the south-west.
4. Every US flu season since 1997

Folding 29 years of weekly data into season by week lines up the winter peaks. The 2025-26 season peaked at 8.3% of outpatient visits in late December, the highest in the record, ahead of 2024-25 (7.8%) and the October 2009 H1N1 pandemic wave (7.7%). Data: CDC ILINet (FluView), weighted %ILI, via the Delphi Epidata API.
Tip: the summer weeks CDC didn't report before 2002-03 are NaN and draw as holes instead of fake zeros.
Get the data from CDC ILINet (FluView), national weighted %ILI by MMWR week, via the DelphiEpidata API (fluview, region nat, from week 40 of 1997)and shape it into a table(columns: season like "2009-10", season_week (1 = MMWR week 40), epiweek, wili; drop week 53and the season in progress).Build a plotly go.Surface: season week on x (ticks Oct, Dec, Feb, ...), season index on y(label a few seasons), %ILI on z, a cream-to-dark-red sequential scale, missing weeks as NaN.High camera (eye x=-0.7, y=1.45, z=1.75) so tall seasons don't hide others.Title with the highest peak. Also make a heatmap of the same grid with a category y axis.
Another way to see it: as a heatmap every season is visible, including the spring and autumn waves of the 2009 pandemic and the flat 2020-21 season.

5. US death rates by age and year

Demographers call this a Lexis surface. US infant mortality fell 84% from 1950 to 2023, and on a log scale adult death rates rise in a straight line, doubling about every 9 years of age. In 2021, rates at ages 30 to 50 were 38% to 50% higher than in 2019. Data: UN World Population Prospects 2024, life tables (estimates).
Tip: rates span four orders of magnitude, so scene.zaxis.type="log" and surfacecolor=np.log10(rate) make equal steps of height and color equal ratios.
Get the data from the UN World Population Prospects 2024 complete life tables (both sexes,estimates 1950-2023, the WPP2024_Life_Table_Complete file) for the United Statesand shape it into a table(columns: year, age (0-100), mx = central death rate, ex = life expectancy).Build a plotly go.Surface: year on x, age on y, mx on z with a log z axis(ticks 0.01% ... 100%), surfacecolor = log10(mx) on a light-to-dark purple scale.Camera from 2023 looking back (eye x=1.9, y=-0.75, z=0.55).Title with the fall in infant mortality and the doubling time of adult death rates(fit log(mx) on age for ages 35-85).
6. What makes a batted ball a hit

Statcast measures every batted ball's speed and angle, and the chance of a hit rises steeply with both. In 2025, balls hit at 100 mph or more between 20 and 36 degrees became hits 72% of the time and home runs 56% of the time, against 33% hits at the same angles below 90 mph. Data: MLB Statcast via Baseball Savant, 120,216 batted balls.
Tip: bins with fewer than 30 balls are set to NaN, so the surface has holes instead of spikes from three or four batted balls.
Get the data from MLB Statcast via Baseball Savant's search CSV export (2025 regular season,every pitch, requested a few days at a time)and shape it into a table(columns: exit_velo_mph bin center (2 mph bins), launch_angle_deg bin center (4-degree bins),balls, hits, home_runs, hit_rate; keep balls in play with launch_speed and launch_angle).Build a plotly go.Surface of hit rate (%) by exit velocity (x) and launch angle (y),limited to 50+ mph and -40 to 70 degrees. Set cells with fewer than 30 balls to NaN.Show the ball count in the hover via customdata. Camera from the low-velocity corner.Title with the hit rate and home run rate for 100+ mph at 20-36 degrees.
7. The Island of Hawai'i from the seafloor

Most of the island is under water. On this grid it rises 9.9 km from the deepest seafloor nearby to the summits of Mauna Kea and Mauna Loa, more than Everest's 8.8 km above sea level, which is why Mauna Kea is often called the tallest mountain on Earth from base to summit. Data: NOAA ETOPO1 global relief model, 1 arc-minute.
Tip: with cmin=-6000 and cmax=4200, the colorscale changes from blues to greens exactly at sea level, and a single contours.z line at 0 draws the coastline.
Get the data from NOAA's ETOPO1 1 arc-minute global relief model (ERDDAP dataset etopo180)for 18.3-20.8 N, 156.6-154.2 Wand shape it into a table(a grid: first column lat, column names are longitudes, values are elevation or depth in meters).Build a plotly go.Surface with x and y in km and 4x vertical exaggeration (state it).- Colorscale with the blue-to-green break exactly at sea level (cmin -6000, cmax 4200).- contours.z with start=0, end=0.1, size=1, black, width 4: the coastline.- Hide the axes, add a colorbar in meters, camera from the southeast.- Title with the relief from the deepest seafloor to the highest summit.
8. The slab of ocean floor sinking under Tonga

The Pacific plate dives under Tonga at the Tonga Trench, and the earthquakes inside it trace the sinking slab. Of the 23,420 earthquakes of magnitude 4.5 and above recorded since 1973, 43% were deeper than 300 km, and the deepest was at 701 km. Data: USGS ANSS ComCat; NOAA ETOPO1 for the seafloor.
Tip: go.Mesh3d with delaunayaxis="z" triangulates the median depth of the deep quakes in half-degree cells into a sheet, with no grid needed.
Get the data from the USGS ANSS Comprehensive Catalog (FDSN event service, magnitude 4.5+,26-15 S, 176-188 E, 1973 to today) and NOAA ETOPO1 relief for the same boxand shape it into tables(quakes: time, lat, lon (0-360 east), depth_km, mag; seafloor: a grid of elevation in meters).Build a plotly 3D figure:- Bin quakes deeper than 100 km into 0.5-degree cells with 10+ events; median depth per cell.- go.Mesh3d of the cell centers with z = -depth, delaunayaxis="z", intensity = depth, orange scale.- go.Scatter3d of every quake as tiny semi-transparent dark points.- go.Surface at z=0 colored by seafloor depth via surfacecolor.- Camera from the south so the westward dip is visible. Title with the share deeper than 300 km.
9. The Arctic is warming fastest

Warming isn't even across latitudes. From 2016 to 2025 the Arctic (65-90°N) averaged 2.6 °C above its 1951-1980 level, 2.5 times the global 1.0 °C, and on the surface the Arctic rises as a wall at the far edge. Data: NASA GISTEMP v4, 2x2 degree grid.
Tip: the latitude axis is spaced by sin(latitude), so each band's width matches its share of the Earth's surface.
Get the data from NASA GISTEMP v4 (gistemp1200_GHCNv4_ERSSTv5 2x2 degree monthly grid,anomalies vs 1951-1980)and shape it into a table(columns: year (1880-2025), lat (2-degree band center), anomaly_c = annual mean of themonthly zonal means, requiring 9+ months).Build a plotly go.Surface: year on x, sin(latitude) on y (labeled 60°S ... 90°N), anomaly on z,smoothed with a 5-year running mean along years.- Diverging blue-white-red scale centered on 0, clipped at ±3 °C.- If the y tick labels end up behind the surface, add them as Scatter3d text before 1880.- Title with the Arctic vs global average for 2016-2025, computed from the raw annual values.
Another way to see it: a heatmap of the raw annual values needs no camera, and the Arctic's red band since 2000 is the first thing you see.

10. The Keeling curve, folded by year

The Keeling curve rises from 316 ppm in 1959 to 427 ppm in 2025, and every year northern forests pull it down by about 6 ppm in summer and release it in winter. Folded into month by year, the trend becomes the slope and the cycle becomes stripes. Data: NOAA Global Monitoring Laboratory and Scripps, Mauna Loa monthly means.
Tip: height is the CO2 level, but surfacecolor is only the seasonal part (each month minus NOAA's deseasonalized value), so one surface shows two things.
Get the data from NOAA GML's Mauna Loa monthly mean CO2 file (co2_mm_mlo.csv, with NOAA'sdeseasonalized values)and shape it into a table(columns: year, month, co2_ppm, deseasonalized_ppm, seasonal_ppm = co2_ppm - deseasonalized_ppm).Build a plotly go.Surface: month on x (Jan-Dec), year on y, co2_ppm on z,surfacecolor = seasonal_ppm on a green-white-brown scale from -4 to +4.Camera from the December side (eye x=1.85, y=-1.35, z=0.95).Title with the first and last full-year means and the average yearly swing of the last 10 years.