diff --git a/CMakeLists.txt b/CMakeLists.txt
index 06b24d877c27..979f3d38008e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2460,6 +2460,7 @@ add_executable(
   src/test/frametest.cpp
   src/test/globaltrackcache_test.cpp
   src/test/hotcuecontrol_test.cpp
+  src/test/hotcueorderbyposition_test.cpp
   src/test/imageutils_test.cpp
   src/test/indexrange_test.cpp
   src/test/itunesxmlimportertest.cpp
diff --git a/src/audio/frame.h b/src/audio/frame.h
index 6e921c4dea8f..61eab19ac478 100644
--- a/src/audio/frame.h
+++ b/src/audio/frame.h
@@ -5,6 +5,7 @@
 #include <limits>
 
 #include "engine/engine.h"
+#include "util/compatibility/qhash.h"
 #include "util/fpclassify.h"
 
 namespace mixxx {
@@ -247,6 +248,12 @@ inline bool operator!=(FramePos frame1, FramePos frame2) {
 
 QDebug operator<<(QDebug dbg, FramePos arg);
 
+inline qhash_seed_t qHash(
+        FramePos pos,
+        qhash_seed_t seed = 0) {
+    return static_cast<qhash_seed_t>(pos.value(), seed);
+}
+
 constexpr FramePos kInvalidFramePos = FramePos(FramePos::kInvalidValue);
 constexpr FramePos kStartFramePos = FramePos(FramePos::kStartValue);
 } // namespace audio
diff --git a/src/controllers/controlpickermenu.cpp b/src/controllers/controlpickermenu.cpp
index dd636fbbc692..b4bd5e346641 100644
--- a/src/controllers/controlpickermenu.cpp
+++ b/src/controllers/controlpickermenu.cpp
@@ -515,20 +515,6 @@ ControlPickerMenu::ControlPickerMenu(QWidget* pParent)
 
     // Hotcues
     QMenu* pHotcueMainMenu = addSubmenu(tr("Hotcues"));
-    QString hotcueActivateTitle = tr("Hotcue %1");
-    QString hotcueClearTitle = tr("Clear Hotcue %1");
-    QString hotcueSetTitle = tr("Set Hotcue %1");
-    QString hotcueGotoTitle = tr("Jump To Hotcue %1");
-    QString hotcueGotoAndStopTitle = tr("Jump To Hotcue %1 And Stop");
-    QString hotcueGotoAndPlayTitle = tr("Jump To Hotcue %1 And Play");
-    QString hotcuePreviewTitle = tr("Preview Hotcue %1");
-    QString hotcueActivateDescription = tr("Set, preview from or jump to hotcue %1");
-    QString hotcueClearDescription = tr("Clear hotcue %1");
-    QString hotcueSetDescription = tr("Set hotcue %1");
-    QString hotcueGotoDescription = tr("Jump to hotcue %1");
-    QString hotcueGotoAndStopDescription = tr("Jump to hotcue %1 and stop");
-    QString hotcueGotoAndPlayDescription = tr("Jump to hotcue %1 and play");
-    QString hotcuePreviewDescription = tr("Preview from hotcue %1");
     addDeckControl("shift_cues_earlier",
             tr("Shift cue points earlier"),
             tr("Shift cue points 10 milliseconds earlier"),
@@ -545,6 +531,29 @@ ControlPickerMenu::ControlPickerMenu(QWidget* pParent)
             tr("Shift cue points later (fine)"),
             tr("Shift cue points 1 millisecond later"),
             pHotcueMainMenu);
+    addDeckControl("sort_hotcues",
+            tr("Sort hotcues by position"),
+            tr("Sort hotcues by position"),
+            pHotcueMainMenu);
+    addDeckControl("sort_hotcues_remove_offsets",
+            tr("Sort hotcues by position (remove offsets)"),
+            tr("Sort hotcues by position (remove offsets)"),
+            pHotcueMainMenu);
+
+    const QString hotcueActivateTitle = tr("Hotcue %1");
+    const QString hotcueClearTitle = tr("Clear Hotcue %1");
+    const QString hotcueSetTitle = tr("Set Hotcue %1");
+    const QString hotcueGotoTitle = tr("Jump To Hotcue %1");
+    const QString hotcueGotoAndStopTitle = tr("Jump To Hotcue %1 And Stop");
+    const QString hotcueGotoAndPlayTitle = tr("Jump To Hotcue %1 And Play");
+    const QString hotcuePreviewTitle = tr("Preview Hotcue %1");
+    const QString hotcueActivateDescription = tr("Set, preview from or jump to hotcue %1");
+    const QString hotcueClearDescription = tr("Clear hotcue %1");
+    const QString hotcueSetDescription = tr("Set hotcue %1");
+    const QString hotcueGotoDescription = tr("Jump to hotcue %1");
+    const QString hotcueGotoAndStopDescription = tr("Jump to hotcue %1 and stop");
+    const QString hotcueGotoAndPlayDescription = tr("Jump to hotcue %1 and play");
+    const QString hotcuePreviewDescription = tr("Preview from hotcue %1");
     // add menus for hotcues 1-16.
     // though, keep the menu small put additional hotcues in a separate menu,
     // but don't create that submenu for less than 4 additional hotcues.
diff --git a/src/engine/controls/cuecontrol.cpp b/src/engine/controls/cuecontrol.cpp
index 80c2c08121f6..0ec33756d47e 100644
--- a/src/engine/controls/cuecontrol.cpp
+++ b/src/engine/controls/cuecontrol.cpp
@@ -128,6 +128,20 @@ CueControl::CueControl(const QString& group,
     m_pPassthrough->connectValueChanged(this,
             &CueControl::passthroughChanged,
             Qt::DirectConnection);
+
+    m_pSortHotcuesByPos = std::make_unique<ControlPushButton>(ConfigKey(group, "sort_hotcues"));
+    connect(m_pSortHotcuesByPos.get(),
+            &ControlObject::valueChanged,
+            this,
+            &CueControl::setHotcueIndicesSortedByPosition,
+            Qt::DirectConnection);
+    m_pSortHotcuesByPosCompress = std::make_unique<ControlPushButton>(
+            ConfigKey(group, "sort_hotcues_remove_offsets"));
+    connect(m_pSortHotcuesByPosCompress.get(),
+            &ControlObject::valueChanged,
+            this,
+            &CueControl::setHotcueIndicesSortedByPositionCompress,
+            Qt::DirectConnection);
 }
 
 CueControl::~CueControl() {
@@ -448,6 +462,18 @@ void CueControl::passthroughChanged(double enabled) {
     }
 }
 
+void CueControl::setHotcueIndicesSortedByPosition(double v) {
+    if (v > 0 && m_pLoadedTrack) {
+        m_pLoadedTrack->setHotcueIndicesSortedByPosition(HotcueSortMode::KeepOffsets);
+    }
+}
+
+void CueControl::setHotcueIndicesSortedByPositionCompress(double v) {
+    if (v > 0 && m_pLoadedTrack) {
+        m_pLoadedTrack->setHotcueIndicesSortedByPosition(HotcueSortMode::RemoveOffsets);
+    }
+}
+
 void CueControl::attachCue(const CuePointer& pCue, HotcueControl* pControl) {
     VERIFY_OR_DEBUG_ASSERT(pControl) {
         return;
diff --git a/src/engine/controls/cuecontrol.h b/src/engine/controls/cuecontrol.h
index 64bdbb51ef57..d9864cf1d85a 100644
--- a/src/engine/controls/cuecontrol.h
+++ b/src/engine/controls/cuecontrol.h
@@ -240,6 +240,9 @@ class CueControl : public EngineControl {
 
     void passthroughChanged(double v);
 
+    void setHotcueIndicesSortedByPosition(double v);
+    void setHotcueIndicesSortedByPositionCompress(double v);
+
     void cueSet(double v);
     void cueClear(double v);
     void cueGoto(double v);
@@ -362,6 +365,9 @@ class CueControl : public EngineControl {
 
     parented_ptr<ControlProxy> m_pPassthrough;
 
+    std::unique_ptr<ControlPushButton> m_pSortHotcuesByPos;
+    std::unique_ptr<ControlPushButton> m_pSortHotcuesByPosCompress;
+
     QAtomicPointer<HotcueControl> m_pCurrentSavedLoopControl;
 
     // Tells us which controls map to which hotcue
diff --git a/src/test/hotcueorderbyposition_test.cpp b/src/test/hotcueorderbyposition_test.cpp
new file mode 100644
index 000000000000..5648758c2d47
--- /dev/null
+++ b/src/test/hotcueorderbyposition_test.cpp
@@ -0,0 +1,80 @@
+// #include "library/coverart.h"
+// #include "sources/soundsourceproxy.h"
+// #include "test/soundsourceproviderregistration.h"
+#include "test/mixxxtest.h"
+#include "track/cue.h"
+#include "track/track.h"
+
+// Test for updating track metadata and cover art from files.
+class TrackHotcueOrderByPosTest : public MixxxTest {
+  protected:
+    static TrackPointer newTestTrack() {
+        return Track::newTemporary(
+                QDir(MixxxTest::getOrInitTestDir().filePath(QStringLiteral("track-test-data"))),
+                "THOBP.mp3");
+    }
+};
+
+TEST_F(TrackHotcueOrderByPosTest, orderHotcuesKeepOffsets) {
+    auto pTrack = newTestTrack();
+    pTrack->markClean();
+
+    // create hotcues with ascending position but unordered indices
+    CuePointer pHotcue1 = pTrack->createAndAddCue(mixxx::CueType::HotCue,
+            2,
+            mixxx::audio::FramePos(100),
+            mixxx::audio::kInvalidFramePos);
+    CuePointer pHotcue2 = pTrack->createAndAddCue(mixxx::CueType::HotCue,
+            1,
+            mixxx::audio::FramePos(200),
+            mixxx::audio::kInvalidFramePos);
+    CuePointer pHotcue3 = pTrack->createAndAddCue(mixxx::CueType::HotCue,
+            7,
+            mixxx::audio::FramePos(300),
+            mixxx::audio::kInvalidFramePos,
+            mixxx::PredefinedColorPalettes::kDefaultCueColor);
+    CuePointer pHotcue4 = pTrack->createAndAddCue(mixxx::CueType::HotCue,
+            5,
+            mixxx::audio::FramePos(400),
+            mixxx::audio::kInvalidFramePos);
+
+    pTrack->setHotcueIndicesSortedByPosition(HotcueSortMode::KeepOffsets);
+
+    // Hotcues indices by position should now be 1 2 5 7
+    EXPECT_EQ(pHotcue1->getHotCue(), 1);
+    EXPECT_EQ(pHotcue2->getHotCue(), 2);
+    EXPECT_EQ(pHotcue3->getHotCue(), 5);
+    EXPECT_EQ(pHotcue4->getHotCue(), 7);
+}
+
+TEST_F(TrackHotcueOrderByPosTest, orderHotcuesRemoveOffsets) {
+    auto pTrack = newTestTrack();
+    pTrack->markClean();
+
+    // create hotcues with ascending position but unordered indices
+    CuePointer pHotcue1 = pTrack->createAndAddCue(mixxx::CueType::HotCue,
+            2,
+            mixxx::audio::FramePos(100),
+            mixxx::audio::kInvalidFramePos);
+    CuePointer pHotcue2 = pTrack->createAndAddCue(mixxx::CueType::HotCue,
+            1,
+            mixxx::audio::FramePos(200),
+            mixxx::audio::kInvalidFramePos);
+    CuePointer pHotcue3 = pTrack->createAndAddCue(mixxx::CueType::HotCue,
+            7,
+            mixxx::audio::FramePos(300),
+            mixxx::audio::kInvalidFramePos,
+            mixxx::PredefinedColorPalettes::kDefaultCueColor);
+    CuePointer pHotcue4 = pTrack->createAndAddCue(mixxx::CueType::HotCue,
+            5,
+            mixxx::audio::FramePos(400),
+            mixxx::audio::kInvalidFramePos);
+
+    pTrack->setHotcueIndicesSortedByPosition(HotcueSortMode::RemoveOffsets);
+
+    // Hotcues indices by position should now be 0 1 2 3
+    EXPECT_EQ(pHotcue1->getHotCue(), 0);
+    EXPECT_EQ(pHotcue2->getHotCue(), 1);
+    EXPECT_EQ(pHotcue3->getHotCue(), 2);
+    EXPECT_EQ(pHotcue4->getHotCue(), 3);
+}
diff --git a/src/track/track.cpp b/src/track/track.cpp
index ad53892533f8..e01a37996aa5 100644
--- a/src/track/track.cpp
+++ b/src/track/track.cpp
@@ -1004,6 +1004,63 @@ void Track::shiftBeatsMillis(double milliseconds) {
     }
 }
 
+void Track::setHotcueIndicesSortedByPosition(HotcueSortMode sortMode) {
+    auto locked = lockMutex(&m_qMutex);
+
+    // Populate lists of positions and indices
+    QList<int> indices;
+    QList<mixxx::audio::FramePos> positions;
+    indices.reserve(m_cuePoints.size());
+    positions.reserve(m_cuePoints.size());
+    for (const CuePointer& pCue : std::as_const(m_cuePoints)) {
+        if (pCue->getType() != mixxx::CueType::HotCue) {
+            continue;
+        }
+        const auto pos = pCue->getPosition();
+        positions.append(pos);
+        if (sortMode == HotcueSortMode::KeepOffsets) {
+            // We shall keep empty hotcues (start offset, gaps), so we need
+            // to store the indices
+            indices.append(pCue->getHotCue());
+        }
+    }
+
+    std::sort(positions.begin(), positions.end());
+    if (sortMode == HotcueSortMode::KeepOffsets) {
+        DEBUG_ASSERT(positions.size() == indices.size());
+        std::sort(indices.begin(), indices.end());
+    }
+
+    // The actual sorting:
+    // re-map hotcue positions to indices in ascending order
+    QHash<mixxx::audio::FramePos, int> posIndexHash;
+    if (sortMode == HotcueSortMode::RemoveOffsets) {
+        // Assign new indices, start with 0
+        int index = mixxx::kFirstHotCueIndex;
+        for (int i = 0; i < positions.size(); i++) {
+            posIndexHash.insert(positions[i], index);
+            index++;
+        }
+    } else { // HotcueSortMode::KeepOffsets
+        // Assign sorted indices
+        for (int i = 0; i < positions.size(); i++) {
+            posIndexHash.insert(positions[i], indices[i]);
+        }
+    }
+
+    // Finally set new indices on hotcues
+    for (CuePointer& pCue : m_cuePoints) {
+        if (pCue->getType() != mixxx::CueType::HotCue) {
+            continue;
+        }
+        int newIndex = posIndexHash.take(pCue->getPosition());
+        pCue->setHotCue(newIndex);
+    }
+
+    markDirtyAndUnlock(&locked);
+    emit cuesUpdated();
+}
+
 void Track::analysisFinished() {
     emit analyzed();
 }
diff --git a/src/track/track.h b/src/track/track.h
index 446347914a99..d97204860f77 100644
--- a/src/track/track.h
+++ b/src/track/track.h
@@ -299,6 +299,11 @@ class Track : public QObject {
     /// Shift beatgrid by a constant offset
     void shiftBeatsMillis(double milliseconds);
 
+    /// Set hoctues' indices sorted by their frame position.
+    /// If compress is true, indices are consecutive and start at 0.
+    /// Set false to sort only, ie. keep empty hotcues before and in between.
+    void setHotcueIndicesSortedByPosition(HotcueSortMode sortMode);
+
     // Call when analysis is done.
     void analysisFinished();
 
diff --git a/src/track/track_decl.h b/src/track/track_decl.h
index bcc5d66e783d..c3edb26daec4 100644
--- a/src/track/track_decl.h
+++ b/src/track/track_decl.h
@@ -28,6 +28,11 @@ enum class ExportTrackMetadataResult {
     Skipped,
 };
 
+enum class HotcueSortMode {
+    KeepOffsets,
+    RemoveOffsets,
+};
+
 // key for control to open/close the decks' track menus
 const QString kShowTrackMenuKey = QStringLiteral("show_track_menu");
 
diff --git a/src/widget/wtrackmenu.cpp b/src/widget/wtrackmenu.cpp
index b20c18fe584a..db9f48aa40d3 100644
--- a/src/widget/wtrackmenu.cpp
+++ b/src/widget/wtrackmenu.cpp
@@ -476,6 +476,23 @@ void WTrackMenu::createActions() {
 
         m_pClearAllMetadataAction = make_parented<QAction>(tr("All"), m_pClearMetadataMenu);
         connect(m_pClearAllMetadataAction, &QAction::triggered, this, &WTrackMenu::slotClearAllMetadata);
+
+        m_pSortHotcuesByPositionCompressAction = make_parented<QAction>(
+                tr("Sort hotcues by position (remove offsets)"), m_pClearMetadataMenu);
+        connect(m_pSortHotcuesByPositionCompressAction,
+                &QAction::triggered,
+                this,
+                [this]() {
+                    slotSortHotcuesByPosition(HotcueSortMode::RemoveOffsets);
+                });
+        m_pSortHotcuesByPositionAction = make_parented<QAction>(
+                tr("Sort hotcues by position"), m_pClearMetadataMenu);
+        connect(m_pSortHotcuesByPositionAction,
+                &QAction::triggered,
+                this,
+                [this]() {
+                    slotSortHotcuesByPosition(HotcueSortMode::KeepOffsets);
+                });
     }
 
     if (featureIsEnabled(Feature::BPM)) {
@@ -576,6 +593,11 @@ void WTrackMenu::createActions() {
 }
 
 void WTrackMenu::setupActions() {
+    addAction(m_pSortHotcuesByPositionCompressAction);
+    addAction(m_pSortHotcuesByPositionAction);
+
+    addSeparator();
+
     if (featureIsEnabled(Feature::SearchRelated)) {
         addMenu(m_pSearchRelatedMenu);
     }
@@ -2147,6 +2169,19 @@ class ResetOutroTrackPointerOperation : public mixxx::TrackPointerOperation {
     }
 };
 
+class SortHotcuesByPositionTrackPointerOperation : public mixxx::TrackPointerOperation {
+  public:
+    explicit SortHotcuesByPositionTrackPointerOperation(HotcueSortMode sortMode)
+            : m_sortMode(sortMode) {
+    }
+
+  private:
+    void doApply(const TrackPointer& pTrack) const override {
+        pTrack->setHotcueIndicesSortedByPosition(m_sortMode);
+    }
+    HotcueSortMode m_sortMode;
+};
+
 } // anonymous namespace
 
 void WTrackMenu::slotResetMainCue() {
@@ -2199,6 +2234,28 @@ void WTrackMenu::slotClearHotCues() {
             &trackOperator);
 }
 
+void WTrackMenu::slotSortHotcuesByPosition(HotcueSortMode sortMode) {
+    QString progressLabelText;
+    switch (sortMode) {
+    case HotcueSortMode::RemoveOffsets:
+        progressLabelText = tr(
+                "Sorting hotcues of %n track(s) by position (remove offsets)",
+                "",
+                getTrackCount());
+        break;
+    case HotcueSortMode::KeepOffsets:
+    default:
+        progressLabelText =
+                tr("Sorting hotcues of %n track(s) by position", "", getTrackCount());
+        break;
+    }
+    const auto trackOperator =
+            SortHotcuesByPositionTrackPointerOperation(sortMode);
+    applyTrackPointerOperation(
+            progressLabelText,
+            &trackOperator);
+}
+
 namespace {
 
 class ResetKeysTrackPointerOperation : public mixxx::TrackPointerOperation {
diff --git a/src/widget/wtrackmenu.h b/src/widget/wtrackmenu.h
index 4ff3678f3ea4..f2f7234aeb73 100644
--- a/src/widget/wtrackmenu.h
+++ b/src/widget/wtrackmenu.h
@@ -12,6 +12,7 @@
 #include "library/trackprocessing.h"
 #include "preferences/usersettings.h"
 #include "track/beats.h"
+#include "track/track_decl.h"
 #include "track/trackref.h"
 #include "util/color/rgbcolor.h"
 #include "util/parented_ptr.h"
@@ -168,6 +169,9 @@ class WTrackMenu : public QMenu {
     void slotScaleBpm(mixxx::Beats::BpmScale scale);
     void slotUndoBeatsChange();
 
+    // Hotcues
+    void slotSortHotcuesByPosition(HotcueSortMode sortMode);
+
     // Info and metadata
     void slotUpdateReplayGainFromPregain();
     void slotShowDlgTagFetcher();
@@ -364,6 +368,8 @@ class WTrackMenu : public QMenu {
     parented_ptr<QAction> m_pClearKeyAction;
     parented_ptr<QAction> m_pClearReplayGainAction;
     parented_ptr<QAction> m_pClearAllMetadataAction;
+    parented_ptr<QAction> m_pSortHotcuesByPositionAction{};
+    parented_ptr<QAction> m_pSortHotcuesByPositionCompressAction{};
 
     const UserSettingsPointer m_pConfig;
     Library* const m_pLibrary;