diff --git a/ip/ip_common.h b/ip/ip_common.h
index 53866d7a..8b0a6426 100644
--- a/ip/ip_common.h
+++ b/ip/ip_common.h
@@ -158,6 +158,32 @@ void xdp_dump(FILE *fp, struct rtattr *tb, bool link, bool details);
 __u32 ipvrf_get_table(const char *name);
 int name_is_vrf(const char *name);
 
+/* ipstats.c */
+enum ipstats_stat_desc_kind {
+	IPSTATS_STAT_DESC_KIND_LEAF,
+	IPSTATS_STAT_DESC_KIND_GROUP,
+};
+
+struct ipstats_stat_dump_filters;
+struct ipstats_stat_show_attrs;
+
+struct ipstats_stat_desc {
+	const char *name;
+	enum ipstats_stat_desc_kind kind;
+	union {
+		struct {
+			const struct ipstats_stat_desc **subs;
+			size_t nsubs;
+		};
+		struct {
+			void (*pack)(struct ipstats_stat_dump_filters *filters,
+				     const struct ipstats_stat_desc *desc);
+			int (*show)(struct ipstats_stat_show_attrs *attrs,
+				    const struct ipstats_stat_desc *desc);
+		};
+	};
+};
+
 #ifndef	INFINITY_LIFE_TIME
 #define     INFINITY_LIFE_TIME      0xFFFFFFFFU
 #endif
diff --git a/ip/ipstats.c b/ip/ipstats.c
index 1f5b3f77..79f7b1ff 100644
--- a/ip/ipstats.c
+++ b/ip/ipstats.c
@@ -1,19 +1,624 @@
 // SPDX-License-Identifier: GPL-2.0+
+#include <assert.h>
 #include <errno.h>
 
 #include "utils.h"
 #include "ip_common.h"
 
+struct ipstats_stat_dump_filters {
+	/* mask[0] filters outer attributes. Then individual nests have their
+	 * filtering mask at the index of the nested attribute.
+	 */
+	__u32 mask[IFLA_STATS_MAX + 1];
+};
+
+struct ipstats_stat_show_attrs {
+	struct if_stats_msg *ifsm;
+	int len;
+
+	/* tbs[0] contains top-level attribute table. Then individual nests have
+	 * their attribute tables at the index of the nested attribute.
+	 */
+	struct rtattr **tbs[IFLA_STATS_MAX + 1];
+};
+
+static const char *const ipstats_levels[] = {
+	"group",
+	"subgroup",
+};
+
+enum {
+	IPSTATS_LEVELS_COUNT = ARRAY_SIZE(ipstats_levels),
+};
+
+struct ipstats_sel {
+	const char *sel[IPSTATS_LEVELS_COUNT];
+};
+
+struct ipstats_stat_enabled_one {
+	const struct ipstats_stat_desc *desc;
+	struct ipstats_sel sel;
+};
+
+struct ipstats_stat_enabled {
+	struct ipstats_stat_enabled_one *enabled;
+	size_t nenabled;
+};
+
+static const unsigned int ipstats_stat_ifla_max[] = {
+	[0] = IFLA_STATS_MAX,
+	[IFLA_STATS_LINK_XSTATS] = LINK_XSTATS_TYPE_MAX,
+	[IFLA_STATS_LINK_XSTATS_SLAVE] = LINK_XSTATS_TYPE_MAX,
+	[IFLA_STATS_LINK_OFFLOAD_XSTATS] = IFLA_OFFLOAD_XSTATS_MAX,
+	[IFLA_STATS_AF_SPEC] = AF_MAX - 1,
+};
+
+static_assert(ARRAY_SIZE(ipstats_stat_ifla_max) == IFLA_STATS_MAX + 1,
+	      "An IFLA_STATS attribute is missing from the ifla_max table");
+
+static int
+ipstats_stat_show_attrs_alloc_tb(struct ipstats_stat_show_attrs *attrs,
+				 unsigned int group)
+{
+	unsigned int ifla_max;
+	int err;
+
+	assert(group < ARRAY_SIZE(ipstats_stat_ifla_max));
+	assert(group < ARRAY_SIZE(attrs->tbs));
+	ifla_max = ipstats_stat_ifla_max[group];
+	assert(ifla_max != 0);
+
+	if (attrs->tbs[group])
+		return 0;
+
+	attrs->tbs[group] = calloc(ifla_max + 1, sizeof(*attrs->tbs[group]));
+	if (attrs->tbs[group] == NULL)
+		return -ENOMEM;
+
+	if (group == 0)
+		err = parse_rtattr(attrs->tbs[group], ifla_max,
+				   IFLA_STATS_RTA(attrs->ifsm), attrs->len);
+	else
+		err = parse_rtattr_nested(attrs->tbs[group], ifla_max,
+					  attrs->tbs[0][group]);
+
+	if (err != 0) {
+		free(attrs->tbs[group]);
+		attrs->tbs[group] = NULL;
+	}
+	return err;
+}
+
+static void
+ipstats_stat_show_attrs_free(struct ipstats_stat_show_attrs *attrs)
+{
+	size_t i;
+
+	for (i = 0; i < ARRAY_SIZE(attrs->tbs); i++)
+		free(attrs->tbs[i]);
+}
+
+static const struct ipstats_stat_desc *ipstats_stat_desc_toplev_subs[] = {
+};
+
+static const struct ipstats_stat_desc ipstats_stat_desc_toplev_group = {
+	.name = "top-level",
+	.kind = IPSTATS_STAT_DESC_KIND_GROUP,
+	.subs = ipstats_stat_desc_toplev_subs,
+	.nsubs = ARRAY_SIZE(ipstats_stat_desc_toplev_subs),
+};
+
+static void ipstats_show_group(const struct ipstats_sel *sel)
+{
+	int i;
+
+	for (i = 0; i < IPSTATS_LEVELS_COUNT; i++) {
+		if (sel->sel[i] == NULL)
+			break;
+		print_string(PRINT_JSON, ipstats_levels[i], NULL, sel->sel[i]);
+		print_string(PRINT_FP, NULL, " %s ", ipstats_levels[i]);
+		print_string(PRINT_FP, NULL, "%s", sel->sel[i]);
+	}
+}
+
+static int
+ipstats_process_ifsm(struct nlmsghdr *answer,
+		     struct ipstats_stat_enabled *enabled)
+{
+	struct ipstats_stat_show_attrs show_attrs = {};
+	const char *dev;
+	int err = 0;
+	int i;
+
+	show_attrs.ifsm = NLMSG_DATA(answer);
+	show_attrs.len = (answer->nlmsg_len -
+			  NLMSG_LENGTH(sizeof(*show_attrs.ifsm)));
+	if (show_attrs.len < 0) {
+		fprintf(stderr, "BUG: wrong nlmsg len %d\n", show_attrs.len);
+		return -EINVAL;
+	}
+
+	err = ipstats_stat_show_attrs_alloc_tb(&show_attrs, 0);
+	if (err != 0) {
+		fprintf(stderr, "Error parsing netlink answer: %s\n",
+			strerror(err));
+		return err;
+	}
+
+	dev = ll_index_to_name(show_attrs.ifsm->ifindex);
+
+	for (i = 0; i < enabled->nenabled; i++) {
+		const struct ipstats_stat_desc *desc = enabled->enabled[i].desc;
+
+		open_json_object(NULL);
+		print_int(PRINT_ANY, "ifindex", "%d:",
+			  show_attrs.ifsm->ifindex);
+		print_color_string(PRINT_ANY, COLOR_IFNAME,
+				   "ifname", " %s:", dev);
+		ipstats_show_group(&enabled->enabled[i].sel);
+		err = desc->show(&show_attrs, desc);
+		if (err != 0)
+			goto out;
+		close_json_object();
+		print_nl();
+	}
+
+out:
+	ipstats_stat_show_attrs_free(&show_attrs);
+	return err;
+}
+
+static bool
+ipstats_req_should_filter_at(struct ipstats_stat_dump_filters *filters, int at)
+{
+	return filters->mask[at] != 0 &&
+	       filters->mask[at] != (1 << ipstats_stat_ifla_max[at]) - 1;
+}
+
+static int
+ipstats_req_add_filters(struct ipstats_req *req, void *data)
+{
+	struct ipstats_stat_dump_filters dump_filters = {};
+	struct ipstats_stat_enabled *enabled = data;
+	bool get_filters = false;
+	int i;
+
+	for (i = 0; i < enabled->nenabled; i++)
+		enabled->enabled[i].desc->pack(&dump_filters,
+					       enabled->enabled[i].desc);
+
+	for (i = 1; i < ARRAY_SIZE(dump_filters.mask); i++) {
+		if (ipstats_req_should_filter_at(&dump_filters, i)) {
+			get_filters = true;
+			break;
+		}
+	}
+
+	req->ifsm.filter_mask = dump_filters.mask[0];
+	if (get_filters) {
+		struct rtattr *nest;
+
+		nest = addattr_nest(&req->nlh, sizeof(*req),
+				    IFLA_STATS_GET_FILTERS | NLA_F_NESTED);
+
+		for (i = 1; i < ARRAY_SIZE(dump_filters.mask); i++) {
+			if (ipstats_req_should_filter_at(&dump_filters, i))
+				addattr32(&req->nlh, sizeof(*req), i,
+					  dump_filters.mask[i]);
+		}
+
+		addattr_nest_end(&req->nlh, nest);
+	}
+
+	return 0;
+}
+
+static int
+ipstats_show_one(int ifindex, struct ipstats_stat_enabled *enabled)
+{
+	struct ipstats_req req = {
+		.nlh.nlmsg_flags = NLM_F_REQUEST,
+		.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct if_stats_msg)),
+		.nlh.nlmsg_type = RTM_GETSTATS,
+		.ifsm.family = PF_UNSPEC,
+		.ifsm.ifindex = ifindex,
+	};
+	struct nlmsghdr *answer;
+	int err = 0;
+
+	ipstats_req_add_filters(&req, enabled);
+	if (rtnl_talk(&rth, &req.nlh, &answer) < 0)
+		return -2;
+	err = ipstats_process_ifsm(answer, enabled);
+	free(answer);
+
+	return err;
+}
+
+static int ipstats_dump_one(struct nlmsghdr *n, void *arg)
+{
+	struct ipstats_stat_enabled *enabled = arg;
+	int rc;
+
+	rc = ipstats_process_ifsm(n, enabled);
+	if (rc)
+		return rc;
+
+	print_nl();
+	return 0;
+}
+
+static int ipstats_dump(struct ipstats_stat_enabled *enabled)
+{
+	int rc = 0;
+
+	if (rtnl_statsdump_req_filter(&rth, PF_UNSPEC, 0,
+				      ipstats_req_add_filters,
+				      enabled) < 0) {
+		perror("Cannot send dump request");
+		return -2;
+	}
+
+	if (rtnl_dump_filter(&rth, ipstats_dump_one, enabled) < 0) {
+		fprintf(stderr, "Dump terminated\n");
+		rc = -2;
+	}
+
+	fflush(stdout);
+	return rc;
+}
+
+static int
+ipstats_show_do(int ifindex, struct ipstats_stat_enabled *enabled)
+{
+	int rc;
+
+	new_json_obj(json);
+	if (ifindex)
+		rc = ipstats_show_one(ifindex, enabled);
+	else
+		rc = ipstats_dump(enabled);
+	delete_json_obj();
+
+	return rc;
+}
+
+static int ipstats_add_enabled(struct ipstats_stat_enabled_one ens[],
+			       size_t nens,
+			       struct ipstats_stat_enabled *enabled)
+{
+	struct ipstats_stat_enabled_one *new_en;
+
+	new_en = realloc(enabled->enabled,
+			 sizeof(*new_en) * (enabled->nenabled + nens));
+	if (new_en == NULL)
+		return -ENOMEM;
+
+	enabled->enabled = new_en;
+	while (nens-- > 0)
+		enabled->enabled[enabled->nenabled++] = *ens++;
+	return 0;
+}
+
+static void ipstats_select_push(struct ipstats_sel *sel, const char *name)
+{
+	int i;
+
+	for (i = 0; i < IPSTATS_LEVELS_COUNT; i++)
+		if (sel->sel[i] == NULL) {
+			sel->sel[i] = name;
+			return;
+		}
+
+	assert(false);
+}
+
+static int
+ipstats_enable_recursively(const struct ipstats_stat_desc *desc,
+			   struct ipstats_stat_enabled *enabled,
+			   const struct ipstats_sel *sel)
+{
+	bool found = false;
+	size_t i;
+	int err;
+
+	if (desc->kind == IPSTATS_STAT_DESC_KIND_LEAF) {
+		struct ipstats_stat_enabled_one en[] = {{
+			.desc = desc,
+			.sel = *sel,
+		}};
+
+		return ipstats_add_enabled(en, ARRAY_SIZE(en), enabled);
+	}
+
+	for (i = 0; i < desc->nsubs; i++) {
+		struct ipstats_sel subsel = *sel;
+
+		ipstats_select_push(&subsel, desc->subs[i]->name);
+		err = ipstats_enable_recursively(desc->subs[i], enabled,
+						 &subsel);
+		if (err == -ENOENT)
+			continue;
+		if (err != 0)
+			return err;
+		found = true;
+	}
+
+	return found ? 0 : -ENOENT;
+}
+
+static int ipstats_comp_enabled(const void *a, const void *b)
+{
+	const struct ipstats_stat_enabled_one *en_a = a;
+	const struct ipstats_stat_enabled_one *en_b = b;
+
+	if (en_a->desc < en_b->desc)
+		return -1;
+	if (en_a->desc > en_b->desc)
+		return 1;
+
+	return 0;
+}
+
+static void ipstats_enabled_free(struct ipstats_stat_enabled *enabled)
+{
+	free(enabled->enabled);
+}
+
+static const struct ipstats_stat_desc *
+ipstats_stat_desc_find(const struct ipstats_stat_desc *desc,
+		       const char *name)
+{
+	size_t i;
+
+	assert(desc->kind == IPSTATS_STAT_DESC_KIND_GROUP);
+	for (i = 0; i < desc->nsubs; i++) {
+		const struct ipstats_stat_desc *sub = desc->subs[i];
+
+		if (strcmp(sub->name, name) == 0)
+			return sub;
+	}
+
+	return NULL;
+}
+
+static const struct ipstats_stat_desc *
+ipstats_enable_find_stat_desc(struct ipstats_sel *sel)
+{
+	const struct ipstats_stat_desc *toplev = &ipstats_stat_desc_toplev_group;
+	const struct ipstats_stat_desc *desc = toplev;
+	int i;
+
+	for (i = 0; i < IPSTATS_LEVELS_COUNT; i++) {
+		const struct ipstats_stat_desc *next_desc;
+
+		if (sel->sel[i] == NULL)
+			break;
+		if (desc->kind == IPSTATS_STAT_DESC_KIND_LEAF) {
+			fprintf(stderr, "Error: %s %s requested inside leaf %s %s\n",
+				ipstats_levels[i], sel->sel[i],
+				ipstats_levels[i - 1], desc->name);
+			return NULL;
+		}
+
+		next_desc = ipstats_stat_desc_find(desc, sel->sel[i]);
+		if (next_desc == NULL) {
+			fprintf(stderr, "Error: no %s named %s found inside %s\n",
+				ipstats_levels[i], sel->sel[i], desc->name);
+			return NULL;
+		}
+
+		desc = next_desc;
+	}
+
+	return desc;
+}
+
+static int ipstats_enable(struct ipstats_sel *sel,
+			  struct ipstats_stat_enabled *enabled)
+{
+	struct ipstats_stat_enabled new_enabled = {};
+	const struct ipstats_stat_desc *desc;
+	size_t i, j;
+	int err = 0;
+
+	desc = ipstats_enable_find_stat_desc(sel);
+	if (desc == NULL)
+		return -EINVAL;
+
+	err = ipstats_enable_recursively(desc, &new_enabled, sel);
+	if (err != 0)
+		return err;
+
+	err = ipstats_add_enabled(new_enabled.enabled, new_enabled.nenabled,
+				  enabled);
+	if (err != 0)
+		goto out;
+
+	qsort(enabled->enabled, enabled->nenabled, sizeof(*enabled->enabled),
+	      ipstats_comp_enabled);
+
+	for (i = 1, j = 1; i < enabled->nenabled; i++) {
+		if (enabled->enabled[i].desc != enabled->enabled[j - 1].desc)
+			enabled->enabled[j++] = enabled->enabled[i];
+	}
+	enabled->nenabled = j;
+
+out:
+	ipstats_enabled_free(&new_enabled);
+	return err;
+}
+
+static int ipstats_enable_check(struct ipstats_sel *sel,
+				struct ipstats_stat_enabled *enabled)
+{
+	int err;
+	int i;
+
+	err = ipstats_enable(sel, enabled);
+	if (err == -ENOENT) {
+		fprintf(stderr, "The request for");
+		for (i = 0; i < IPSTATS_LEVELS_COUNT; i++)
+			if (sel->sel[i] != NULL)
+				fprintf(stderr, " %s %s",
+					ipstats_levels[i], sel->sel[i]);
+			else
+				break;
+		fprintf(stderr, " did not match any known stats.\n");
+	}
+
+	return err;
+}
+
 static int do_help(void)
 {
+	const struct ipstats_stat_desc *toplev = &ipstats_stat_desc_toplev_group;
+	int i;
+
 	fprintf(stderr,
 		"Usage: ip stats help\n"
+		"       ip stats show [ dev DEV ] [ group GROUP [ subgroup SUBGROUP ] ... ] ...\n"
 		"       ip stats set dev DEV l3_stats { on | off }\n"
 		);
 
+	for (i = 0; i < toplev->nsubs; i++) {
+		const struct ipstats_stat_desc *desc = toplev->subs[i];
+
+		if (i == 0)
+			fprintf(stderr, "GROUP := { %s", desc->name);
+		else
+			fprintf(stderr, " | %s", desc->name);
+	}
+	if (i > 0)
+		fprintf(stderr, " }\n");
+
+	for (i = 0; i < toplev->nsubs; i++) {
+		const struct ipstats_stat_desc *desc = toplev->subs[i];
+		bool opened = false;
+		size_t j;
+
+		if (desc->kind != IPSTATS_STAT_DESC_KIND_GROUP)
+			continue;
+
+		for (j = 0; j < desc->nsubs; j++) {
+			if (j == 0)
+				fprintf(stderr, "%s SUBGROUP := {", desc->name);
+			else
+				fprintf(stderr, " |");
+			fprintf(stderr, " %s", desc->subs[j]->name);
+			opened = true;
+
+			if (desc->subs[j]->kind != IPSTATS_STAT_DESC_KIND_GROUP)
+				continue;
+		}
+		if (opened)
+			fprintf(stderr, " }\n");
+	}
+
 	return 0;
 }
 
+static int ipstats_select(struct ipstats_sel *old_sel,
+			  const char *new_sel, int level,
+			  struct ipstats_stat_enabled *enabled)
+{
+	int err;
+	int i;
+
+	for (i = 0; i < level; i++) {
+		if (old_sel->sel[i] == NULL) {
+			fprintf(stderr, "Error: %s %s requested without selecting a %s first\n",
+				ipstats_levels[level], new_sel,
+				ipstats_levels[i]);
+			return -EINVAL;
+		}
+	}
+
+	for (i = level; i < IPSTATS_LEVELS_COUNT; i++) {
+		if (old_sel->sel[i] != NULL) {
+			err = ipstats_enable_check(old_sel, enabled);
+			if (err)
+				return err;
+			break;
+		}
+	}
+
+	old_sel->sel[level] = new_sel;
+	for (i = level + 1; i < IPSTATS_LEVELS_COUNT; i++)
+		old_sel->sel[i] = NULL;
+
+	return 0;
+}
+
+static int ipstats_show(int argc, char **argv)
+{
+	struct ipstats_stat_enabled enabled = {};
+	struct ipstats_sel sel = {};
+	const char *dev = NULL;
+	int ifindex;
+	int err;
+	int i;
+
+	while (argc > 0) {
+		if (strcmp(*argv, "dev") == 0) {
+			NEXT_ARG();
+			if (dev != NULL)
+				duparg2("dev", *argv);
+			if (check_ifname(*argv))
+				invarg("\"dev\" not a valid ifname", *argv);
+			dev = *argv;
+		} else if (strcmp(*argv, "help") == 0) {
+			do_help();
+			return 0;
+		} else {
+			bool found_level = false;
+
+			for (i = 0; i < ARRAY_SIZE(ipstats_levels); i++) {
+				if (strcmp(*argv, ipstats_levels[i]) == 0) {
+					NEXT_ARG();
+					err = ipstats_select(&sel, *argv, i,
+							     &enabled);
+					if (err)
+						goto err;
+
+					found_level = true;
+				}
+			}
+
+			if (!found_level) {
+				fprintf(stderr, "What is \"%s\"?\n", *argv);
+				do_help();
+				err = -EINVAL;
+				goto err;
+			}
+		}
+
+		NEXT_ARG_FWD();
+	}
+
+	/* Push whatever was given. */
+	err = ipstats_enable_check(&sel, &enabled);
+	if (err)
+		goto err;
+
+	if (dev) {
+		ifindex = ll_name_to_index(dev);
+		if (!ifindex) {
+			err = nodev(dev);
+			goto err;
+		}
+	} else {
+		ifindex = 0;
+	}
+
+
+	err = ipstats_show_do(ifindex, &enabled);
+
+err:
+	ipstats_enabled_free(&enabled);
+	return err;
+}
+
 static int ipstats_set_do(int ifindex, int at, bool enable)
 {
 	struct ipstats_req req = {
@@ -92,11 +697,16 @@ int do_ipstats(int argc, char **argv)
 	int rc;
 
 	if (argc == 0) {
-		do_help();
-		rc = -1;
+		rc = ipstats_show(0, NULL);
 	} else if (strcmp(*argv, "help") == 0) {
 		do_help();
 		rc = 0;
+	} else if (strcmp(*argv, "show") == 0) {
+		/* Invoking "stats show" implies one -s. Passing -d adds one
+		 * more -s.
+		 */
+		show_stats += show_details + 1;
+		rc = ipstats_show(argc-1, argv+1);
 	} else if (strcmp(*argv, "set") == 0) {
 		rc = ipstats_set(argc-1, argv+1);
 	} else {
