diff --git a/frontend/src/metabase/css/query_builder.css b/frontend/src/metabase/css/query_builder.css
index 551ded5e24ebb8fd4b87dd02d7ac54a2d18de8a3..1f95889a027cc1e07c9d0bbc2333aff40aae4678 100644
--- a/frontend/src/metabase/css/query_builder.css
+++ b/frontend/src/metabase/css/query_builder.css
@@ -687,3 +687,18 @@
 .ParameterValuePickerNoPopover input::-webkit-input-placeholder {
   color: var(--color-text-medium);
 }
+
+/* TEMP - spot for value labels */
+text.value-label-outline {
+  font-weight: 900;
+  stroke-width: 4px;
+  stroke: var(--color-text-white);
+}
+
+text.value-label {
+  font-weight: 900;
+}
+
+.Dashboard--night text.value-label-outline {
+  stroke: var(--night-mode-card);
+}
diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js
index 8e02745e56746e2b1219304bcfa4cbd5d3d6ce3c..304e2f1a1f0702dccd4e8bd1dc042467f19a804f 100644
--- a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js
+++ b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js
@@ -6,6 +6,7 @@ import _ from "underscore";
 import { color } from "metabase/lib/colors";
 import { clipPathReference } from "metabase/lib/dom";
 import { adjustYAxisTicksIfNeeded } from "./apply_axis";
+import { isHistogramBar } from "./renderer_utils";
 
 const X_LABEL_MIN_SPACING = 2; // minimum space we want to leave between labels
 const X_LABEL_ROTATE_90_THRESHOLD = 24; // tick width breakpoint for switching from 45° to 90°
@@ -245,6 +246,128 @@ function onRenderVoronoiHover(chart) {
     .order();
 }
 
+function onRenderValueLabels(chart, formatYValue, [data]) {
+  const hasDuplicateX = new Set(data.map(([x]) => x)).size < data.length;
+  if (
+    !chart.settings["graph.show_values"] || // setting is off
+    chart.settings["stackable.stack_type"] === "normalized" || // no normalized
+    chart.series.length > 1 || // no multiseries
+    hasDuplicateX // need unique x values
+  ) {
+    return;
+  }
+  const showAll = chart.settings["graph.label_value_frequency"] === "all";
+  const { display } = chart.settings.series(chart.series[0]);
+
+  // Update `data` to use named x/y and include `showLabelBelow`.
+  // We need to do that before data is filtered to show every nth value.
+  data = data.map(([x, y], i) => {
+    const isLocalMin =
+      // first point or prior is greater than y
+      (i === 0 || data[i - 1][1] > y) &&
+      // last point point or next is greater than y
+      (i === data.length - 1 || data[i + 1][1] > y);
+    const showLabelBelow = isLocalMin && display === "line";
+    return { x, y, showLabelBelow };
+  });
+
+  // use the chart body so things line up properly
+  const parent = chart.svg().select(".chart-body");
+
+  const xScale = chart.x();
+  const yScale = chart.y();
+
+  // Ordinal bar charts and histograms need extra logic to center the label.
+  let xShift = 0;
+  if (xScale.rangeBand) {
+    xShift += xScale.rangeBand() / 2;
+  }
+  if (isHistogramBar({ settings: chart.settings, chartType: display })) {
+    // this has to match the logic in `doHistogramBarStuff`
+    const [x1, x2] = chart
+      .svg()
+      .selectAll("rect")
+      .flat()
+      .map(r => parseFloat(r.getAttribute("x")));
+    const barWidth = x2 - x1;
+    xShift += barWidth / 2;
+  }
+
+  const addLabels = data => {
+    // Safari had an issue with rendering paint-order: stroke. To work around
+    // that, we create two text labels: one for the the black text and another
+    // for the white outline behind it.
+    const labelGroups = parent
+      .append("svg:g")
+      .classed("value-labels", true)
+      .selectAll("g")
+      .data(data)
+      .enter()
+      .append("g")
+      .attr("transform", ({ x, y, showLabelBelow }) => {
+        const xPos = xShift + xScale(x);
+        let yPos = yScale(y) + (showLabelBelow ? 14 : -10);
+        // if the yPos is below the x axis, move it to be above the data point
+        const [yMax] = yScale.range();
+        if (yPos > yMax) {
+          yPos = yScale(y) - 10;
+        }
+        return `translate(${xPos}, ${yPos})`;
+      });
+
+    ["value-label-outline", "value-label"].forEach(klass =>
+      labelGroups
+        .append("text")
+        .attr("class", klass)
+        .attr("text-anchor", "middle")
+        .attr("alignment-baseline", "middle")
+        .text(({ y }) => formatYValue(y, { compact: true })),
+    );
+  };
+
+  let nth;
+  if (showAll) {
+    // show all
+    nth = 1;
+  } else {
+    // auto fit
+    // Render a sample of rows to estimate average label size.
+    // We use that estimate to compute the label interval.
+    const LABEL_PADDING = 6;
+    const MAX_SAMPLE_SIZE = 30;
+    const sampleSize = Math.min(data.length, MAX_SAMPLE_SIZE);
+    // $FlowFixMe
+    addLabels(_.sample(data, sampleSize));
+    const totalWidth = chart
+      .svg()
+      .selectAll(".value-label-outline")
+      .flat()
+      .reduce((sum, label) => sum + label.getBoundingClientRect().width, 0);
+    const labelWidth = totalWidth / sampleSize + LABEL_PADDING;
+
+    const { width: chartWidth } = chart
+      .svg()
+      .select(".axis.x")
+      .node()
+      .getBoundingClientRect();
+
+    chart
+      .svg()
+      .select(".value-labels")
+      .remove();
+    nth = Math.ceil((labelWidth * data.length) / chartWidth);
+  }
+
+  addLabels(data.filter((d, i) => i % nth === 0));
+
+  moveToTop(
+    chart
+      .svg()
+      .select(".value-labels")
+      .node().parentNode,
+  );
+}
+
 function onRenderCleanupGoalAndTrend(chart, onGoalHover, isSplitAxis) {
   // remove dots
   chart.selectAll(".goal .dot, .trend .dot").remove();
@@ -378,7 +501,10 @@ function onRenderAddExtraClickHandlers(chart) {
 }
 
 // the various steps that get called
-function onRender(chart, onGoalHover, isSplitAxis, isStacked) {
+function onRender(
+  chart,
+  { onGoalHover, isSplitAxis, isStacked, formatYValue, datas },
+) {
   onRenderRemoveClipPath(chart);
   onRenderMoveContentToTop(chart);
   onRenderReorderCharts(chart);
@@ -387,6 +513,7 @@ function onRender(chart, onGoalHover, isSplitAxis, isStacked) {
   onRenderEnableDots(chart);
   onRenderVoronoiHover(chart);
   onRenderCleanupGoalAndTrend(chart, onGoalHover, isSplitAxis); // do this before hiding x-axis
+  onRenderValueLabels(chart, formatYValue, datas);
   onRenderHideDisabledLabels(chart);
   onRenderHideDisabledAxis(chart);
   onRenderHideBadAxis(chart);
@@ -610,15 +737,8 @@ function beforeRender(chart) {
 // +-------------------------------------------------------------------------------------------------------------------+
 
 /// once chart has rendered and we can access the SVG, do customizations to axis labels / etc that you can't do through dc.js
-export default function lineAndBarOnRender(
-  chart,
-  onGoalHover,
-  isSplitAxis,
-  isStacked,
-) {
+export default function lineAndBarOnRender(chart, args) {
   beforeRender(chart);
-  chart.on("renderlet.on-render", () =>
-    onRender(chart, onGoalHover, isSplitAxis, isStacked),
-  );
+  chart.on("renderlet.on-render", () => onRender(chart, args));
   chart.render();
 }
diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
index 9faf2198840c9d0dd58df51f6bf7e72314364c36..3455ea17a8eee9c36dc6c67bd53771e604e9b6e5 100644
--- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
+++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
@@ -30,6 +30,7 @@ import {
   applyChartQuantitativeXAxis,
   applyChartOrdinalXAxis,
   applyChartYAxis,
+  getYValueFormatter,
 } from "./apply_axis";
 
 import { setupTooltips } from "./apply_tooltips";
@@ -886,12 +887,13 @@ export default function lineAreaBar(
   parent.render();
 
   // apply any on-rendering functions (this code lives in `LineAreaBarPostRenderer`)
-  lineAndBarOnRender(
-    parent,
+  lineAndBarOnRender(parent, {
     onGoalHover,
-    yAxisProps.isSplit,
-    isStacked(parent.settings, datas),
-  );
+    isSplitAxis: yAxisProps.isSplit,
+    isStacked: isStacked(parent.settings, datas),
+    formatYValue: getYValueFormatter(parent, series, yAxisProps.yExtent),
+    datas,
+  });
 
   // only ordinal axis can display "null" values
   if (isOrdinal(parent.settings)) {
diff --git a/frontend/src/metabase/visualizations/lib/apply_axis.js b/frontend/src/metabase/visualizations/lib/apply_axis.js
index 3b57de676ab73798572d0d295d2508cfb5ddff5e..4bf688c40165111ebabb81baf507d4af2a1eb4ac 100644
--- a/frontend/src/metabase/visualizations/lib/apply_axis.js
+++ b/frontend/src/metabase/visualizations/lib/apply_axis.js
@@ -356,18 +356,7 @@ export function applyChartYAxis(chart, series, yExtent, axisName) {
   }
 
   if (axis.setting("axis_enabled")) {
-    // special case for normalized stacked charts
-    // for normalized stacked charts the y-axis is a percentage number. In Javascript, 0.07 * 100.0 = 7.000000000000001 (try it) so we
-    // round that number to get something nice like "7". Then we append "%" to get a nice tick like "7%"
-    if (chart.settings["stackable.stack_type"] === "normalized") {
-      axis.axis().tickFormat(value => Math.round(value * 100) + "%");
-    } else {
-      const metricColumn = series[0].data.cols[1];
-      axis.axis().tickFormat(value => {
-        value = maybeRoundValueToZero(value, yExtent);
-        return formatValue(value, chart.settings.column(metricColumn));
-      });
-    }
+    axis.axis().tickFormat(getYValueFormatter(chart, series, yExtent));
     chart.renderHorizontalGridLines(true);
     adjustYAxisTicksIfNeeded(axis.axis(), chart.height());
   } else {
@@ -427,3 +416,18 @@ export function applyChartYAxis(chart, series, yExtent, axisName) {
     axis.scale(scale.domain([min, max]));
   }
 }
+
+export function getYValueFormatter(chart, series, yExtent) {
+  // special case for normalized stacked charts
+  // for normalized stacked charts the y-axis is a percentage number. In Javascript, 0.07 * 100.0 = 7.000000000000001 (try it) so we
+  // round that number to get something nice like "7". Then we append "%" to get a nice tick like "7%"
+  if (chart.settings["stackable.stack_type"] === "normalized") {
+    return value => Math.round(value * 100) + "%";
+  }
+  const metricColumn = series[0].data.cols[1];
+  const columnSettings = chart.settings.column(metricColumn);
+  return (value, options) => {
+    const roundedValue = maybeRoundValueToZero(value, yExtent);
+    return formatValue(roundedValue, { ...columnSettings, ...options });
+  };
+}
diff --git a/frontend/src/metabase/visualizations/lib/settings/graph.js b/frontend/src/metabase/visualizations/lib/settings/graph.js
index b716c9f33bb4f9a8ced1f58f4d50e048d6d6442a..6656b5ca00d164c59935753ebe984de88e5e59a4 100644
--- a/frontend/src/metabase/visualizations/lib/settings/graph.js
+++ b/frontend/src/metabase/visualizations/lib/settings/graph.js
@@ -278,7 +278,7 @@ export const STACKABLE_SETTINGS = {
 export const GRAPH_GOAL_SETTINGS = {
   "graph.show_goal": {
     section: t`Display`,
-    title: t`Show goal`,
+    title: t`Goal line`,
     widget: "toggle",
     default: false,
   },
@@ -300,7 +300,7 @@ export const GRAPH_GOAL_SETTINGS = {
   },
   "graph.show_trendline": {
     section: t`Display`,
-    title: t`Show trend line`,
+    title: t`Trend line`,
     widget: "toggle",
     default: false,
     getHidden: (series, vizSettings) => {
@@ -311,6 +311,38 @@ export const GRAPH_GOAL_SETTINGS = {
   },
 };
 
+// with more than this many rows, don't display values on top of bars by default
+const AUTO_SHOW_VALUES_MAX_ROWS = 25;
+
+export const GRAPH_DISPLAY_VALUES_SETTINGS = {
+  "graph.show_values": {
+    section: t`Display`,
+    title: t`Show values on data points`,
+    widget: "toggle",
+    getHidden: (series, vizSettings) =>
+      series.length > 1 || vizSettings["stackable.stack_type"] === "normalized",
+    getDefault: ([{ card, data }]) =>
+      card.display === "bar" && data.rows.length < AUTO_SHOW_VALUES_MAX_ROWS,
+  },
+  "graph.label_value_frequency": {
+    section: t`Display`,
+    title: t`Values to show`,
+    widget: "radio",
+    getHidden: (series, vizSettings) =>
+      series.length > 1 ||
+      vizSettings["graph.show_values"] !== true ||
+      vizSettings["stackable.stack_type"] === "normalized",
+    props: {
+      options: [
+        { name: t`As many as can fit nicely`, value: "fit" },
+        { name: t`All`, value: "all" },
+      ],
+    },
+    default: "fit",
+    readDependencies: ["graph.show_values"],
+  },
+};
+
 export const GRAPH_COLORS_SETTINGS = {
   // DEPRECATED: replaced with "color" series setting
   "graph.colors": {},
diff --git a/frontend/src/metabase/visualizations/visualizations/AreaChart.jsx b/frontend/src/metabase/visualizations/visualizations/AreaChart.jsx
index e66b4638df036ae6325fdd74d2510c82ff719232..f30ce548c4bb28bc9487005eaadf4709fe6558db 100644
--- a/frontend/src/metabase/visualizations/visualizations/AreaChart.jsx
+++ b/frontend/src/metabase/visualizations/visualizations/AreaChart.jsx
@@ -12,6 +12,7 @@ import {
   GRAPH_GOAL_SETTINGS,
   GRAPH_COLORS_SETTINGS,
   GRAPH_AXIS_SETTINGS,
+  GRAPH_DISPLAY_VALUES_SETTINGS,
 } from "../lib/settings/graph";
 
 export default class AreaChart extends LineAreaBarChart {
@@ -27,6 +28,7 @@ export default class AreaChart extends LineAreaBarChart {
     ...GRAPH_GOAL_SETTINGS,
     ...GRAPH_COLORS_SETTINGS,
     ...GRAPH_AXIS_SETTINGS,
+    ...GRAPH_DISPLAY_VALUES_SETTINGS,
   };
 
   static renderer = areaRenderer;
diff --git a/frontend/src/metabase/visualizations/visualizations/BarChart.jsx b/frontend/src/metabase/visualizations/visualizations/BarChart.jsx
index 270422a76076d28886f6de5b656285749f8bcd23..f48d3b13f37af5dff9842f21a2ebe7213f35acb0 100644
--- a/frontend/src/metabase/visualizations/visualizations/BarChart.jsx
+++ b/frontend/src/metabase/visualizations/visualizations/BarChart.jsx
@@ -11,6 +11,7 @@ import {
   GRAPH_GOAL_SETTINGS,
   GRAPH_COLORS_SETTINGS,
   GRAPH_AXIS_SETTINGS,
+  GRAPH_DISPLAY_VALUES_SETTINGS,
 } from "../lib/settings/graph";
 
 export default class BarChart extends LineAreaBarChart {
@@ -25,6 +26,7 @@ export default class BarChart extends LineAreaBarChart {
     ...GRAPH_GOAL_SETTINGS,
     ...GRAPH_COLORS_SETTINGS,
     ...GRAPH_AXIS_SETTINGS,
+    ...GRAPH_DISPLAY_VALUES_SETTINGS,
   };
 
   static renderer = barRenderer;
diff --git a/frontend/src/metabase/visualizations/visualizations/LineChart.jsx b/frontend/src/metabase/visualizations/visualizations/LineChart.jsx
index b99ae590a044bd19095abfb1e9cfb37f58b0db14..d536a8843608b1d44f06821a6a8946b895cca7f2 100644
--- a/frontend/src/metabase/visualizations/visualizations/LineChart.jsx
+++ b/frontend/src/metabase/visualizations/visualizations/LineChart.jsx
@@ -10,6 +10,7 @@ import {
   GRAPH_GOAL_SETTINGS,
   GRAPH_COLORS_SETTINGS,
   GRAPH_AXIS_SETTINGS,
+  GRAPH_DISPLAY_VALUES_SETTINGS,
 } from "../lib/settings/graph";
 
 export default class LineChart extends LineAreaBarChart {
@@ -24,6 +25,7 @@ export default class LineChart extends LineAreaBarChart {
     ...GRAPH_GOAL_SETTINGS,
     ...GRAPH_COLORS_SETTINGS,
     ...GRAPH_AXIS_SETTINGS,
+    ...GRAPH_DISPLAY_VALUES_SETTINGS,
   };
 
   static renderer = lineRenderer;