diff --git a/compiler/rustc_lint_defs/src/builtin.rs b/compiler/rustc_lint_defs/src/builtin.rs
index 8af8f40d69f56..ee5a74ffd0faa 100644
--- a/compiler/rustc_lint_defs/src/builtin.rs
+++ b/compiler/rustc_lint_defs/src/builtin.rs
@@ -146,6 +146,7 @@ declare_lint_pass! {
         UNUSED_MACROS,
         UNUSED_MACRO_RULES,
         UNUSED_MUT,
+        UNUSED_PUB_ITEMS_IN_BINARY,
         UNUSED_QUALIFICATIONS,
         UNUSED_UNSAFE,
         UNUSED_VARIABLES,
@@ -782,6 +783,37 @@ declare_lint! {
     "detect unused, unexported items"
 }
 
+declare_lint! {
+    /// The `unused_pub_items_in_binary` lint detects unused `pub` items in
+    /// executable crates.
+    ///
+    /// ### Example
+    ///
+    /// ```rust
+    /// #![deny(unused_pub_items_in_binary)]
+    ///
+    /// pub fn unused_pub_fn() {}
+    ///
+    /// fn main() {}
+    /// ```
+    ///
+    /// {{produces}}
+    ///
+    /// ### Explanation
+    ///
+    /// In executable crates, `pub` items are often implementation details
+    /// rather than part of an external API. This lint helps find those items
+    /// when they are never used.
+    ///
+    /// This lint only applies to executable crates. In library crates, public
+    /// items are considered part of the crate's API and are not reported by
+    /// this lint.
+    pub UNUSED_PUB_ITEMS_IN_BINARY,
+    Allow,
+    "detect public items in executable crates that are never used",
+    crate_level_only
+}
+
 declare_lint! {
     /// The `unused_attributes` lint detects attributes that were not used by
     /// the compiler.
diff --git a/compiler/rustc_middle/src/middle/dead_code.rs b/compiler/rustc_middle/src/middle/dead_code.rs
new file mode 100644
index 0000000000000..e199bb37ccffa
--- /dev/null
+++ b/compiler/rustc_middle/src/middle/dead_code.rs
@@ -0,0 +1,23 @@
+use rustc_data_structures::fx::FxIndexSet;
+use rustc_hir::def_id::{DefId, LocalDefIdMap, LocalDefIdSet};
+use rustc_macros::HashStable;
+
+/// A single snapshot of dead-code liveness analysis state.
+#[derive(Clone, Debug, HashStable)]
+pub struct DeadCodeLivenessSnapshot {
+    pub live_symbols: LocalDefIdSet,
+    /// Maps each ADT to derived traits (for example `Debug` and `Clone`) that should be ignored
+    /// when checking for dead code diagnostics.
+    pub ignored_derived_traits: LocalDefIdMap<FxIndexSet<DefId>>,
+}
+
+/// Dead-code liveness data for both analysis phases.
+///
+/// `pre_deferred_seeding` is computed before reachable-public and `#[allow(dead_code)]` seeding,
+/// and is used for lint `unused_pub_items_in_binary`.
+/// `final_result` is the final liveness snapshot used for lint `dead_code`.
+#[derive(Clone, Debug, HashStable)]
+pub struct DeadCodeLivenessSummary {
+    pub pre_deferred_seeding: DeadCodeLivenessSnapshot,
+    pub final_result: DeadCodeLivenessSnapshot,
+}
diff --git a/compiler/rustc_middle/src/middle/mod.rs b/compiler/rustc_middle/src/middle/mod.rs
index 5739d132b66ca..7d4c34defa80d 100644
--- a/compiler/rustc_middle/src/middle/mod.rs
+++ b/compiler/rustc_middle/src/middle/mod.rs
@@ -1,4 +1,5 @@
 pub mod codegen_fn_attrs;
+pub mod dead_code;
 pub mod debugger_visualizer;
 pub mod deduced_param_attrs;
 pub mod dependency_format;
diff --git a/compiler/rustc_middle/src/queries.rs b/compiler/rustc_middle/src/queries.rs
index dd36cbf1b8f6f..e4eacaaa9538e 100644
--- a/compiler/rustc_middle/src/queries.rs
+++ b/compiler/rustc_middle/src/queries.rs
@@ -64,9 +64,7 @@ use rustc_errors::{ErrorGuaranteed, catch_fatal_errors};
 use rustc_hir as hir;
 use rustc_hir::attrs::{EiiDecl, EiiImpl, StrippedCfgItem};
 use rustc_hir::def::{DefKind, DocLinkResMap};
-use rustc_hir::def_id::{
-    CrateNum, DefId, DefIdMap, LocalDefId, LocalDefIdMap, LocalDefIdSet, LocalModDefId,
-};
+use rustc_hir::def_id::{CrateNum, DefId, DefIdMap, LocalDefId, LocalDefIdSet, LocalModDefId};
 use rustc_hir::lang_items::{LangItem, LanguageItems};
 use rustc_hir::{ItemLocalId, ItemLocalMap, PreciseCapturingArgKind, TraitCandidate};
 use rustc_index::IndexVec;
@@ -87,6 +85,7 @@ use crate::infer::canonical::{self, Canonical};
 use crate::lint::LintExpectation;
 use crate::metadata::ModChild;
 use crate::middle::codegen_fn_attrs::{CodegenFnAttrs, SanitizerFnAttrs};
+use crate::middle::dead_code::DeadCodeLivenessSummary;
 use crate::middle::debugger_visualizer::DebuggerVisualizerFile;
 use crate::middle::deduced_param_attrs::DeducedParamAttrs;
 use crate::middle::exported_symbols::{ExportedSymbol, SymbolExportInfo};
@@ -1199,13 +1198,8 @@ rustc_queries! {
         desc { "checking liveness of variables in `{}`", tcx.def_path_str(key.to_def_id()) }
     }
 
-    /// Return the live symbols in the crate for dead code check.
-    ///
-    /// The second return value maps from ADTs to ignored derived traits (e.g. Debug and Clone).
-    query live_symbols_and_ignored_derived_traits(_: ()) -> Result<&'tcx (
-        LocalDefIdSet,
-        LocalDefIdMap<FxIndexSet<DefId>>,
-    ), ErrorGuaranteed> {
+    /// Return dead-code liveness summary for the crate.
+    query live_symbols_and_ignored_derived_traits(_: ()) -> Result<&'tcx DeadCodeLivenessSummary, ErrorGuaranteed> {
         arena_cache
         desc { "finding live symbols in crate" }
     }
diff --git a/compiler/rustc_passes/src/check_attr.rs b/compiler/rustc_passes/src/check_attr.rs
index d0cd8511f09f9..78e1ba4f887ff 100644
--- a/compiler/rustc_passes/src/check_attr.rs
+++ b/compiler/rustc_passes/src/check_attr.rs
@@ -1682,6 +1682,16 @@ impl<'tcx> CheckAttrVisitor<'tcx> {
                         return;
                     }
                 }
+            } else if hir_id == CRATE_HIR_ID
+                && attr.has_any_name(&[sym::allow, sym::warn, sym::deny, sym::forbid, sym::expect])
+                && let Some(meta) = attr.meta_item_list()
+                && meta.iter().any(|meta| {
+                    meta.meta_item()
+                        .is_some_and(|item| item.path == sym::unused_pub_items_in_binary)
+                })
+                && !self.tcx.crate_types().contains(&CrateType::Executable)
+            {
+                errors::UnusedNote::NoEffectUnusedPubItemsInBinary
             } else if attr.has_name(sym::default_method_body_is_const) {
                 errors::UnusedNote::DefaultMethodBodyConst
             } else {
diff --git a/compiler/rustc_passes/src/dead.rs b/compiler/rustc_passes/src/dead.rs
index d5ef9ee9335c5..3f00b06f40499 100644
--- a/compiler/rustc_passes/src/dead.rs
+++ b/compiler/rustc_passes/src/dead.rs
@@ -13,18 +13,21 @@ use rustc_errors::{ErrorGuaranteed, MultiSpan};
 use rustc_hir::def::{CtorOf, DefKind, Res};
 use rustc_hir::def_id::{DefId, LocalDefId, LocalModDefId};
 use rustc_hir::intravisit::{self, Visitor};
-use rustc_hir::{self as hir, Node, PatKind, QPath, find_attr};
+use rustc_hir::{self as hir, ForeignItemId, ItemId, Node, PatKind, QPath, find_attr};
 use rustc_middle::middle::codegen_fn_attrs::CodegenFnAttrFlags;
+use rustc_middle::middle::dead_code::{DeadCodeLivenessSnapshot, DeadCodeLivenessSummary};
 use rustc_middle::middle::privacy::Level;
 use rustc_middle::query::Providers;
 use rustc_middle::ty::{self, AssocTag, TyCtxt};
 use rustc_middle::{bug, span_bug};
-use rustc_session::lint::builtin::DEAD_CODE;
-use rustc_session::lint::{self, LintExpectationId};
+use rustc_session::config::CrateType;
+use rustc_session::lint::builtin::{DEAD_CODE, UNUSED_PUB_ITEMS_IN_BINARY};
+use rustc_session::lint::{self, Lint, LintExpectationId};
 use rustc_span::{Symbol, kw};
 
 use crate::errors::{
-    ChangeFields, IgnoredDerivedImpls, MultipleDeadCodes, ParentInfo, UselessAssignment,
+    ChangeFields, IgnoredDerivedImpls, MultipleDeadCodes, ParentInfo, UnusedPubItemsInBinaryNote,
+    UselessAssignment,
 };
 
 /// Any local definition that may call something in its body block should be explored. For example,
@@ -750,23 +753,21 @@ fn has_allow_dead_code_or_lang_attr(
 fn maybe_record_as_seed<'tcx>(
     tcx: TyCtxt<'tcx>,
     owner_id: hir::OwnerId,
-    worklist: &mut Vec<(LocalDefId, ComesFromAllowExpect)>,
+    push_into_worklist: &mut impl FnMut(LocalDefId, ComesFromAllowExpect),
     unsolved_items: &mut Vec<LocalDefId>,
 ) {
     let allow_dead_code = has_allow_dead_code_or_lang_attr(tcx, owner_id.def_id);
     if let Some(comes_from_allow) = allow_dead_code {
-        worklist.push((owner_id.def_id, comes_from_allow));
+        push_into_worklist(owner_id.def_id, comes_from_allow);
     }
 
     match tcx.def_kind(owner_id) {
         DefKind::Enum => {
             if let Some(comes_from_allow) = allow_dead_code {
                 let adt = tcx.adt_def(owner_id);
-                worklist.extend(
-                    adt.variants()
-                        .iter()
-                        .map(|variant| (variant.def_id.expect_local(), comes_from_allow)),
-                );
+                for variant in adt.variants().iter() {
+                    push_into_worklist(variant.def_id.expect_local(), comes_from_allow);
+                }
             }
         }
         DefKind::AssocFn | DefKind::AssocConst { .. } | DefKind::AssocTy => {
@@ -781,7 +782,7 @@ fn maybe_record_as_seed<'tcx>(
                             && let Some(comes_from_allow) =
                                 has_allow_dead_code_or_lang_attr(tcx, trait_item_local_def_id)
                         {
-                            worklist.push((owner_id.def_id, comes_from_allow));
+                            push_into_worklist(owner_id.def_id, comes_from_allow);
                         }
 
                         // We only care about associated items of traits,
@@ -802,7 +803,7 @@ fn maybe_record_as_seed<'tcx>(
                     && let Some(comes_from_allow) =
                         has_allow_dead_code_or_lang_attr(tcx, trait_def_id)
                 {
-                    worklist.push((owner_id.def_id, comes_from_allow));
+                    push_into_worklist(owner_id.def_id, comes_from_allow);
                 }
 
                 unsolved_items.push(owner_id.def_id);
@@ -810,53 +811,61 @@ fn maybe_record_as_seed<'tcx>(
         }
         DefKind::GlobalAsm => {
             // global_asm! is always live.
-            worklist.push((owner_id.def_id, ComesFromAllowExpect::No));
+            push_into_worklist(owner_id.def_id, ComesFromAllowExpect::No);
         }
         DefKind::Const { .. } => {
             if tcx.item_name(owner_id.def_id) == kw::Underscore {
                 // `const _` is always live, as that syntax only exists for the side effects
                 // of type checking and evaluating the constant expression, and marking them
                 // as dead code would defeat that purpose.
-                worklist.push((owner_id.def_id, ComesFromAllowExpect::No));
+                push_into_worklist(owner_id.def_id, ComesFromAllowExpect::No);
             }
         }
         _ => {}
     }
 }
 
-fn create_and_seed_worklist(
-    tcx: TyCtxt<'_>,
-) -> (Vec<(LocalDefId, ComesFromAllowExpect)>, Vec<LocalDefId>) {
-    let effective_visibilities = &tcx.effective_visibilities(());
-    let mut unsolved_impl_item = Vec::new();
-    let mut worklist = effective_visibilities
-        .iter()
-        .filter_map(|(&id, effective_vis)| {
-            effective_vis
-                .is_public_at_level(Level::Reachable)
-                .then_some(id)
-                .map(|id| (id, ComesFromAllowExpect::No))
-        })
-        // Seed entry point
-        .chain(
-            tcx.entry_fn(())
-                .and_then(|(def_id, _)| def_id.as_local().map(|id| (id, ComesFromAllowExpect::No))),
-        )
-        .collect::<Vec<_>>();
+struct SeedWorklists {
+    worklist: Vec<(LocalDefId, ComesFromAllowExpect)>,
+    deferred_seeds: Vec<(LocalDefId, ComesFromAllowExpect)>,
+    unsolved_items: Vec<LocalDefId>,
+}
+
+fn create_and_seed_worklist(tcx: TyCtxt<'_>) -> SeedWorklists {
+    let mut unsolved_items = Vec::new();
+    let mut deferred_seeds = Vec::new();
+    let mut worklist = Vec::new();
+
+    if let Some((def_id, _)) = tcx.entry_fn(())
+        && let Some(local_def_id) = def_id.as_local()
+    {
+        worklist.push((local_def_id, ComesFromAllowExpect::No));
+    }
 
+    for (id, effective_vis) in tcx.effective_visibilities(()).iter() {
+        if effective_vis.is_public_at_level(Level::Reachable) {
+            deferred_seeds.push((*id, ComesFromAllowExpect::No));
+        }
+    }
+
+    let mut push_into_worklist = |def_id, comes_from_allow| match comes_from_allow {
+        ComesFromAllowExpect::Yes => deferred_seeds.push((def_id, ComesFromAllowExpect::Yes)),
+        ComesFromAllowExpect::No => worklist.push((def_id, ComesFromAllowExpect::No)),
+    };
     let crate_items = tcx.hir_crate_items(());
     for id in crate_items.owners() {
-        maybe_record_as_seed(tcx, id, &mut worklist, &mut unsolved_impl_item);
+        maybe_record_as_seed(tcx, id, &mut push_into_worklist, &mut unsolved_items);
     }
 
-    (worklist, unsolved_impl_item)
+    SeedWorklists { worklist, deferred_seeds, unsolved_items }
 }
 
 fn live_symbols_and_ignored_derived_traits(
     tcx: TyCtxt<'_>,
     (): (),
-) -> Result<(LocalDefIdSet, LocalDefIdMap<FxIndexSet<DefId>>), ErrorGuaranteed> {
-    let (worklist, mut unsolved_items) = create_and_seed_worklist(tcx);
+) -> Result<DeadCodeLivenessSummary, ErrorGuaranteed> {
+    let SeedWorklists { worklist, deferred_seeds, mut unsolved_items } =
+        create_and_seed_worklist(tcx);
     let mut symbol_visitor = MarkSymbolVisitor {
         worklist,
         tcx,
@@ -869,6 +878,28 @@ fn live_symbols_and_ignored_derived_traits(
         ignore_variant_stack: vec![],
         ignored_derived_traits: Default::default(),
     };
+    mark_live_symbols_and_ignored_derived_traits(&mut symbol_visitor, &mut unsolved_items)?;
+    let pre_deferred_seeding = DeadCodeLivenessSnapshot {
+        live_symbols: symbol_visitor.live_symbols.clone(),
+        ignored_derived_traits: symbol_visitor.ignored_derived_traits.clone(),
+    };
+
+    symbol_visitor.worklist.extend(deferred_seeds);
+    mark_live_symbols_and_ignored_derived_traits(&mut symbol_visitor, &mut unsolved_items)?;
+
+    Ok(DeadCodeLivenessSummary {
+        pre_deferred_seeding,
+        final_result: DeadCodeLivenessSnapshot {
+            live_symbols: symbol_visitor.live_symbols,
+            ignored_derived_traits: symbol_visitor.ignored_derived_traits,
+        },
+    })
+}
+
+fn mark_live_symbols_and_ignored_derived_traits(
+    symbol_visitor: &mut MarkSymbolVisitor<'_>,
+    unsolved_items: &mut Vec<LocalDefId>,
+) -> Result<(), ErrorGuaranteed> {
     if let ControlFlow::Break(guar) = symbol_visitor.mark_live_symbols() {
         return Err(guar);
     }
@@ -894,7 +925,7 @@ fn live_symbols_and_ignored_derived_traits(
         }));
     }
 
-    Ok((symbol_visitor.live_symbols, symbol_visitor.ignored_derived_traits))
+    Ok(())
 }
 
 struct DeadItem {
@@ -905,6 +936,7 @@ struct DeadItem {
 
 struct DeadVisitor<'tcx> {
     tcx: TyCtxt<'tcx>,
+    target_lint: &'static Lint,
     live_symbols: &'tcx LocalDefIdSet,
     ignored_derived_traits: &'tcx LocalDefIdMap<FxIndexSet<DefId>>,
 }
@@ -948,10 +980,17 @@ impl<'tcx> DeadVisitor<'tcx> {
 
     fn def_lint_level(&self, id: LocalDefId) -> (lint::Level, Option<LintExpectationId>) {
         let hir_id = self.tcx.local_def_id_to_hir_id(id);
-        let level = self.tcx.lint_level_at_node(DEAD_CODE, hir_id);
+        let level = self.tcx.lint_level_at_node(self.target_lint, hir_id);
         (level.level, level.lint_id)
     }
 
+    fn unused_pub_items_in_binary_note(&self) -> Option<UnusedPubItemsInBinaryNote> {
+        self.target_lint
+            .name
+            .eq(UNUSED_PUB_ITEMS_IN_BINARY.name)
+            .then_some(UnusedPubItemsInBinaryNote)
+    }
+
     // # Panics
     // All `dead_codes` must have the same lint level, otherwise we will intentionally ICE.
     // This is because we emit a multi-spanned lint using the lint level of the `dead_codes`'s
@@ -1063,6 +1102,7 @@ impl<'tcx> DeadVisitor<'tcx> {
                     descr,
                     participle,
                     name_list,
+                    unused_pub_items_in_binary_note: self.unused_pub_items_in_binary_note(),
                     change_fields_suggestion: fields_suggestion,
                     parent_info,
                     ignored_derived_impls,
@@ -1099,6 +1139,7 @@ impl<'tcx> DeadVisitor<'tcx> {
                     descr,
                     participle,
                     name_list,
+                    unused_pub_items_in_binary_note: self.unused_pub_items_in_binary_note(),
                     parent_info,
                     ignored_derived_impls,
                     enum_variants_with_same_name,
@@ -1107,7 +1148,7 @@ impl<'tcx> DeadVisitor<'tcx> {
         };
 
         let hir_id = tcx.local_def_id_to_hir_id(first_item.def_id);
-        self.tcx.emit_node_span_lint(DEAD_CODE, hir_id, MultiSpan::from_spans(spans), diag);
+        self.tcx.emit_node_span_lint(self.target_lint, hir_id, MultiSpan::from_spans(spans), diag);
     }
 
     fn warn_multiple(
@@ -1174,17 +1215,55 @@ impl<'tcx> DeadVisitor<'tcx> {
 }
 
 fn check_mod_deathness(tcx: TyCtxt<'_>, module: LocalModDefId) {
-    let Ok((live_symbols, ignored_derived_traits)) =
+    let Ok(DeadCodeLivenessSummary { pre_deferred_seeding, final_result }) =
         tcx.live_symbols_and_ignored_derived_traits(()).as_ref()
     else {
         return;
     };
 
-    let mut visitor = DeadVisitor { tcx, live_symbols, ignored_derived_traits };
-
     let module_items = tcx.hir_module_items(module);
 
-    for item in module_items.free_items() {
+    if tcx.crate_types().contains(&CrateType::Executable) {
+        let is_unused_pub = |def_id: LocalDefId| {
+            tcx.effective_visibilities(()).is_public_at_level(def_id, Level::Reachable)
+                && !pre_deferred_seeding.live_symbols.contains(&def_id)
+        };
+
+        lint_dead_code_or_unused_pub_items_in_binary(
+            tcx,
+            UNUSED_PUB_ITEMS_IN_BINARY,
+            module,
+            &pre_deferred_seeding.live_symbols,
+            &pre_deferred_seeding.ignored_derived_traits,
+            module_items.free_items().filter(|free_item| is_unused_pub(free_item.owner_id.def_id)),
+            module_items
+                .foreign_items()
+                .filter(|foreign_item| is_unused_pub(foreign_item.owner_id.def_id)),
+        );
+    }
+
+    lint_dead_code_or_unused_pub_items_in_binary(
+        tcx,
+        DEAD_CODE,
+        module,
+        &final_result.live_symbols,
+        &final_result.ignored_derived_traits,
+        module_items.free_items(),
+        module_items.foreign_items(),
+    );
+}
+
+fn lint_dead_code_or_unused_pub_items_in_binary<'tcx>(
+    tcx: TyCtxt<'tcx>,
+    target_lint: &'static Lint,
+    module: LocalModDefId,
+    live_symbols: &'tcx LocalDefIdSet,
+    ignored_derived_traits: &'tcx LocalDefIdMap<FxIndexSet<DefId>>,
+    free_items: impl Iterator<Item = ItemId>,
+    foreign_items: impl Iterator<Item = ForeignItemId>,
+) {
+    let mut visitor = DeadVisitor { tcx, target_lint, live_symbols, ignored_derived_traits };
+    for item in free_items {
         let def_kind = tcx.def_kind(item.owner_id);
 
         let mut dead_codes = Vec::new();
@@ -1263,7 +1342,7 @@ fn check_mod_deathness(tcx: TyCtxt<'_>, module: LocalModDefId) {
         }
     }
 
-    for foreign_item in module_items.foreign_items() {
+    for foreign_item in foreign_items {
         visitor.check_definition(foreign_item.owner_id.def_id);
     }
 }
diff --git a/compiler/rustc_passes/src/errors.rs b/compiler/rustc_passes/src/errors.rs
index 2f2c2d0d65bf4..64c8dfc66cba9 100644
--- a/compiler/rustc_passes/src/errors.rs
+++ b/compiler/rustc_passes/src/errors.rs
@@ -302,6 +302,8 @@ pub(crate) enum UnusedNote {
         "the `linker_messages` and `linker_info` lints can only be controlled at the root of a crate that needs to be linked"
     )]
     LinkerMessagesBinaryCrateOnly,
+    #[note("the `unused_pub_items_in_binary` lint has no effect in library crates")]
+    NoEffectUnusedPubItemsInBinary,
 }
 
 #[derive(Diagnostic)]
@@ -948,6 +950,8 @@ pub(crate) enum MultipleDeadCodes<'tcx> {
         participle: &'tcx str,
         name_list: DiagSymbolList,
         #[subdiagnostic]
+        unused_pub_items_in_binary_note: Option<UnusedPubItemsInBinaryNote>,
+        #[subdiagnostic]
         // only on DeadCodes since it's never a problem for tuple struct fields
         enum_variants_with_same_name: Vec<EnumVariantSameName<'tcx>>,
         #[subdiagnostic]
@@ -971,6 +975,8 @@ pub(crate) enum MultipleDeadCodes<'tcx> {
         participle: &'tcx str,
         name_list: DiagSymbolList,
         #[subdiagnostic]
+        unused_pub_items_in_binary_note: Option<UnusedPubItemsInBinaryNote>,
+        #[subdiagnostic]
         change_fields_suggestion: ChangeFields,
         #[subdiagnostic]
         parent_info: Option<ParentInfo<'tcx>>,
@@ -979,6 +985,12 @@ pub(crate) enum MultipleDeadCodes<'tcx> {
     },
 }
 
+#[derive(Subdiagnostic)]
+#[note(
+    "in libraries, `pub` items can be used by dependent crates; in binaries, they cannot, so this `pub` item is unused"
+)]
+pub(crate) struct UnusedPubItemsInBinaryNote;
+
 #[derive(Subdiagnostic)]
 #[note(
     "it is impossible to refer to the {$dead_descr} `{$dead_name}` because it is shadowed by this enum variant with the same name"
diff --git a/compiler/rustc_span/src/symbol.rs b/compiler/rustc_span/src/symbol.rs
index 80d1c91c81ddc..b14352cd4ff2d 100644
--- a/compiler/rustc_span/src/symbol.rs
+++ b/compiler/rustc_span/src/symbol.rs
@@ -2187,6 +2187,7 @@ symbols! {
         unstable_removed,
         untagged_unions,
         unused_imports,
+        unused_pub_items_in_binary,
         unwind,
         unwind_attributes,
         unwind_safe_trait,
diff --git a/tests/ui/lint/unused-pub-items-in-binary/allow-dead-code-transitive.rs b/tests/ui/lint/unused-pub-items-in-binary/allow-dead-code-transitive.rs
new file mode 100644
index 0000000000000..0b939fd435b69
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/allow-dead-code-transitive.rs
@@ -0,0 +1,14 @@
+#![deny(dead_code)]
+#![deny(unused_pub_items_in_binary)]
+
+pub fn g() {} //~ ERROR function `g` is never used
+
+fn h() {}
+
+#[allow(dead_code)]
+fn f() {
+    g();
+    h();
+}
+
+fn main() {}
diff --git a/tests/ui/lint/unused-pub-items-in-binary/allow-dead-code-transitive.stderr b/tests/ui/lint/unused-pub-items-in-binary/allow-dead-code-transitive.stderr
new file mode 100644
index 0000000000000..99710b18c6988
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/allow-dead-code-transitive.stderr
@@ -0,0 +1,15 @@
+error: function `g` is never used
+  --> $DIR/allow-dead-code-transitive.rs:4:8
+   |
+LL | pub fn g() {}
+   |        ^
+   |
+   = note: in libraries, `pub` items can be used by dependent crates; in binaries, they cannot, so this `pub` item is unused
+note: the lint level is defined here
+  --> $DIR/allow-dead-code-transitive.rs:2:9
+   |
+LL | #![deny(unused_pub_items_in_binary)]
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: aborting due to 1 previous error
+
diff --git a/tests/ui/lint/unused-pub-items-in-binary/allow-lint.rs b/tests/ui/lint/unused-pub-items-in-binary/allow-lint.rs
new file mode 100644
index 0000000000000..af737ae6243c5
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/allow-lint.rs
@@ -0,0 +1,12 @@
+#![deny(dead_code)]
+#![allow(unused_pub_items_in_binary)]
+
+pub fn unused_pub_fn() {
+    helper();
+}
+
+fn helper() {}
+
+fn unused_priv_fn() {} //~ ERROR function `unused_priv_fn` is never used
+
+fn main() {}
diff --git a/tests/ui/lint/unused-pub-items-in-binary/allow-lint.stderr b/tests/ui/lint/unused-pub-items-in-binary/allow-lint.stderr
new file mode 100644
index 0000000000000..ae864e50fcf22
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/allow-lint.stderr
@@ -0,0 +1,14 @@
+error: function `unused_priv_fn` is never used
+  --> $DIR/allow-lint.rs:10:4
+   |
+LL | fn unused_priv_fn() {}
+   |    ^^^^^^^^^^^^^^
+   |
+note: the lint level is defined here
+  --> $DIR/allow-lint.rs:1:9
+   |
+LL | #![deny(dead_code)]
+   |         ^^^^^^^^^
+
+error: aborting due to 1 previous error
+
diff --git a/tests/ui/lint/unused-pub-items-in-binary/basic.rs b/tests/ui/lint/unused-pub-items-in-binary/basic.rs
new file mode 100644
index 0000000000000..de76c55fbc683
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/basic.rs
@@ -0,0 +1,12 @@
+#![deny(dead_code)]
+#![deny(unused_pub_items_in_binary)]
+
+pub fn unused_pub_fn() {} //~ ERROR function `unused_pub_fn` is never used
+
+pub fn used_pub_fn() {}
+
+fn unused_priv_fn() {} //~ ERROR function `unused_priv_fn` is never used
+
+fn main() {
+    used_pub_fn();
+}
diff --git a/tests/ui/lint/unused-pub-items-in-binary/basic.stderr b/tests/ui/lint/unused-pub-items-in-binary/basic.stderr
new file mode 100644
index 0000000000000..240e3ae02b5e1
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/basic.stderr
@@ -0,0 +1,27 @@
+error: function `unused_pub_fn` is never used
+  --> $DIR/basic.rs:4:8
+   |
+LL | pub fn unused_pub_fn() {}
+   |        ^^^^^^^^^^^^^
+   |
+   = note: in libraries, `pub` items can be used by dependent crates; in binaries, they cannot, so this `pub` item is unused
+note: the lint level is defined here
+  --> $DIR/basic.rs:2:9
+   |
+LL | #![deny(unused_pub_items_in_binary)]
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: function `unused_priv_fn` is never used
+  --> $DIR/basic.rs:8:4
+   |
+LL | fn unused_priv_fn() {}
+   |    ^^^^^^^^^^^^^^
+   |
+note: the lint level is defined here
+  --> $DIR/basic.rs:1:9
+   |
+LL | #![deny(dead_code)]
+   |         ^^^^^^^^^
+
+error: aborting due to 2 previous errors
+
diff --git a/tests/ui/lint/unused-pub-items-in-binary/deny-lint.rs b/tests/ui/lint/unused-pub-items-in-binary/deny-lint.rs
new file mode 100644
index 0000000000000..78fd6d059d8b7
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/deny-lint.rs
@@ -0,0 +1,6 @@
+#![deny(unused_pub_items_in_binary)]
+#![allow(dead_code)]
+
+pub fn unused_pub_fn() {} //~ ERROR function `unused_pub_fn` is never used
+
+fn main() {}
diff --git a/tests/ui/lint/unused-pub-items-in-binary/deny-lint.stderr b/tests/ui/lint/unused-pub-items-in-binary/deny-lint.stderr
new file mode 100644
index 0000000000000..75b082dc018d8
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/deny-lint.stderr
@@ -0,0 +1,15 @@
+error: function `unused_pub_fn` is never used
+  --> $DIR/deny-lint.rs:4:8
+   |
+LL | pub fn unused_pub_fn() {}
+   |        ^^^^^^^^^^^^^
+   |
+   = note: in libraries, `pub` items can be used by dependent crates; in binaries, they cannot, so this `pub` item is unused
+note: the lint level is defined here
+  --> $DIR/deny-lint.rs:1:9
+   |
+LL | #![deny(unused_pub_items_in_binary)]
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: aborting due to 1 previous error
+
diff --git a/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-dead-code.rs b/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-dead-code.rs
new file mode 100644
index 0000000000000..aa1022b447bad
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-dead-code.rs
@@ -0,0 +1,7 @@
+#![allow(dead_code)]
+#![deny(unused_pub_items_in_binary)]
+
+pub fn unused_pub_fn() {} //~ ERROR function `unused_pub_fn` is never used
+fn unused_priv_fn() {}
+
+fn main() {}
diff --git a/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-dead-code.stderr b/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-dead-code.stderr
new file mode 100644
index 0000000000000..64d938db4e880
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-dead-code.stderr
@@ -0,0 +1,15 @@
+error: function `unused_pub_fn` is never used
+  --> $DIR/interaction-allow-dead-code.rs:4:8
+   |
+LL | pub fn unused_pub_fn() {}
+   |        ^^^^^^^^^^^^^
+   |
+   = note: in libraries, `pub` items can be used by dependent crates; in binaries, they cannot, so this `pub` item is unused
+note: the lint level is defined here
+  --> $DIR/interaction-allow-dead-code.rs:2:9
+   |
+LL | #![deny(unused_pub_items_in_binary)]
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: aborting due to 1 previous error
+
diff --git a/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-unused-pub.rs b/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-unused-pub.rs
new file mode 100644
index 0000000000000..5564e3f96ccd8
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-unused-pub.rs
@@ -0,0 +1,7 @@
+#![allow(unused_pub_items_in_binary)]
+#![deny(dead_code)]
+
+pub fn unused_pub_fn() {}
+fn unused_priv_fn() {} //~ ERROR function `unused_priv_fn` is never used
+
+fn main() {}
diff --git a/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-unused-pub.stderr b/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-unused-pub.stderr
new file mode 100644
index 0000000000000..3c7924257967d
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/interaction-allow-unused-pub.stderr
@@ -0,0 +1,14 @@
+error: function `unused_priv_fn` is never used
+  --> $DIR/interaction-allow-unused-pub.rs:5:4
+   |
+LL | fn unused_priv_fn() {}
+   |    ^^^^^^^^^^^^^^
+   |
+note: the lint level is defined here
+  --> $DIR/interaction-allow-unused-pub.rs:2:9
+   |
+LL | #![deny(dead_code)]
+   |         ^^^^^^^^^
+
+error: aborting due to 1 previous error
+
diff --git a/tests/ui/lint/unused-pub-items-in-binary/interaction-deny-dead-code-only.rs b/tests/ui/lint/unused-pub-items-in-binary/interaction-deny-dead-code-only.rs
new file mode 100644
index 0000000000000..0680ba7d4965d
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/interaction-deny-dead-code-only.rs
@@ -0,0 +1,7 @@
+//@ check-pass
+
+#![deny(dead_code)]
+
+pub fn unused_pub_fn() {}
+
+fn main() {}
diff --git a/tests/ui/lint/unused-pub-items-in-binary/library.rs b/tests/ui/lint/unused-pub-items-in-binary/library.rs
new file mode 100644
index 0000000000000..c44409d837ab5
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/library.rs
@@ -0,0 +1,9 @@
+#![deny(dead_code)]
+#![deny(unused_pub_items_in_binary)]
+//~^ WARN unused attribute
+
+#![crate_type = "lib"]
+
+pub fn unused_pub_fn() {} // Should NOT error because it's a library
+
+fn unused_priv_fn() {} //~ ERROR function `unused_priv_fn` is never used
diff --git a/tests/ui/lint/unused-pub-items-in-binary/library.stderr b/tests/ui/lint/unused-pub-items-in-binary/library.stderr
new file mode 100644
index 0000000000000..071da07e9b044
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/library.stderr
@@ -0,0 +1,23 @@
+warning: unused attribute
+  --> $DIR/library.rs:2:1
+   |
+LL | #![deny(unused_pub_items_in_binary)]
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: remove this attribute
+   |
+   = note: the `unused_pub_items_in_binary` lint has no effect in library crates
+   = note: requested on the command line with `-W unused-attributes`
+
+error: function `unused_priv_fn` is never used
+  --> $DIR/library.rs:9:4
+   |
+LL | fn unused_priv_fn() {}
+   |    ^^^^^^^^^^^^^^
+   |
+note: the lint level is defined here
+  --> $DIR/library.rs:1:9
+   |
+LL | #![deny(dead_code)]
+   |         ^^^^^^^^^
+
+error: aborting due to 1 previous error; 1 warning emitted
+
diff --git a/tests/ui/lint/unused-pub-items-in-binary/no-mangle-items.rs b/tests/ui/lint/unused-pub-items-in-binary/no-mangle-items.rs
new file mode 100644
index 0000000000000..13db56a9e0ad8
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/no-mangle-items.rs
@@ -0,0 +1,14 @@
+#![deny(dead_code)]
+#![deny(unused_pub_items_in_binary)]
+
+#[no_mangle]
+pub fn pub_fn_no_mangle() {}
+
+pub fn unused_pub_fn() {} //~ ERROR function `unused_pub_fn` is never used
+
+#[no_mangle]
+pub fn unused_priv_fn_no_mangle() {}
+
+fn unused_priv_fn() {} //~ ERROR function `unused_priv_fn` is never used
+
+fn main() {}
diff --git a/tests/ui/lint/unused-pub-items-in-binary/no-mangle-items.stderr b/tests/ui/lint/unused-pub-items-in-binary/no-mangle-items.stderr
new file mode 100644
index 0000000000000..18cb33abdf020
--- /dev/null
+++ b/tests/ui/lint/unused-pub-items-in-binary/no-mangle-items.stderr
@@ -0,0 +1,27 @@
+error: function `unused_pub_fn` is never used
+  --> $DIR/no-mangle-items.rs:7:8
+   |
+LL | pub fn unused_pub_fn() {}
+   |        ^^^^^^^^^^^^^
+   |
+   = note: in libraries, `pub` items can be used by dependent crates; in binaries, they cannot, so this `pub` item is unused
+note: the lint level is defined here
+  --> $DIR/no-mangle-items.rs:2:9
+   |
+LL | #![deny(unused_pub_items_in_binary)]
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: function `unused_priv_fn` is never used
+  --> $DIR/no-mangle-items.rs:12:4
+   |
+LL | fn unused_priv_fn() {}
+   |    ^^^^^^^^^^^^^^
+   |
+note: the lint level is defined here
+  --> $DIR/no-mangle-items.rs:1:9
+   |
+LL | #![deny(dead_code)]
+   |         ^^^^^^^^^
+
+error: aborting due to 2 previous errors
+
