diff --git a/capacity-forecast.go b/capacity-forecast.go
new file mode 100644
index 00000000..36915bf1
--- /dev/null
+++ b/capacity-forecast.go
@@ -0,0 +1,99 @@
+//
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package madmin
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+)
+
+// CapacityForecast contains storage capacity predictions based on
+// historical daily snapshots processed through a Kalman filter.
+//
+// Days-until-threshold fields are pointers: nil means "unknown" (for example,
+// not enough history yet, or usage is not growing). A concrete value may be
+// negative when the threshold was already crossed in the past.
+type CapacityForecast struct {
+ CurrentUsedBytes uint64 `json:"currentUsedBytes"`
+ CurrentTotalBytes uint64 `json:"currentTotalBytes"`
+ CurrentUsedPercent float64 `json:"currentUsedPercent"`
+
+ // Days until each usage threshold is reached. The projection comes from
+ // a Kalman filter that integrates up to 365 daily samples with greater
+ // weight on recent observations, so a fresh shift in usage dominates
+ // over older history. nil = unknown (slope is non-positive, or there
+ // is not enough history).
+ DaysUntil80Pct *float64 `json:"daysUntil80Pct,omitempty"`
+ DaysUntil90Pct *float64 `json:"daysUntil90Pct,omitempty"`
+ DaysUntil100Pct *float64 `json:"daysUntil100Pct,omitempty"`
+
+ // GrowthBytesPerDay is the Kalman filter slope expressed in bytes per
+ // day, projected from the daily snapshots in the circular buffer.
+ // It is independent of DailySnapshotCount: the filter is recency
+ // weighted, not a delta between two endpoints.
+ GrowthBytesPerDay int64 `json:"growthBytesPerDay"`
+
+ // DailySnapshotCount is the number of valid daily snapshots currently
+ // held in the year-long circular buffer (range 0..365). The forecast
+ // fields above are only populated when this count reaches the
+ // minimum required for the filter to produce stable estimates.
+ DailySnapshotCount int `json:"dailySnapshotCount"`
+
+ // Worst-case prediction based on the largest single-day growth
+ // observed between any two consecutive data points. nil = unknown.
+ MinDaysUntilFull *float64 `json:"minDaysUntilFull,omitempty"`
+
+ Variance float64 `json:"variance"` // variance of daily usedFraction deltas
+
+ // Smallest and largest day-to-day changes in used bytes observed
+ // between consecutive snapshots. A negative value means space was
+ // freed between those two days.
+ DayMinDeltaBytes int64 `json:"dayMinDeltaBytes"`
+ DayMaxDeltaBytes int64 `json:"dayMaxDeltaBytes"`
+
+ // Recency-weighted predictions from the Kalman filter.
+ RecentGrowthRatePerDay float64 `json:"recentGrowthRatePerDay"`
+ RecentDaysUntilFull *float64 `json:"recentDaysUntilFull,omitempty"`
+}
+
+// CapacityForecast returns a storage capacity forecast based on
+// historical daily snapshots.
+func (adm *AdminClient) CapacityForecast(ctx context.Context) (CapacityForecast, error) {
+ resp, err := adm.executeMethod(ctx,
+ http.MethodGet,
+ requestData{
+ relPath: adminAPIPrefix + "/capacity-forecast",
+ })
+ defer closeResponse(resp)
+ if err != nil {
+ return CapacityForecast{}, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return CapacityForecast{}, httpRespToErrorResponse(resp)
+ }
+
+ var f CapacityForecast
+ if err = json.NewDecoder(resp.Body).Decode(&f); err != nil {
+ return CapacityForecast{}, err
+ }
+
+ return f, nil
+}
diff --git a/capacity-forecast_test.go b/capacity-forecast_test.go
new file mode 100644
index 00000000..4e051499
--- /dev/null
+++ b/capacity-forecast_test.go
@@ -0,0 +1,146 @@
+//
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+package madmin
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func newCapacityForecastTestClient(t *testing.T, serverURL string) *AdminClient {
+ t.Helper()
+ client, err := New(mustParseHost(t, serverURL), "ak", "sk", false)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return client
+}
+
+// TestCapacityForecastRequest verifies the client issues GET against the
+// capacity-forecast admin path.
+func TestCapacityForecastRequest(t *testing.T) {
+ var capturedMethod, capturedPath string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedMethod = r.Method
+ capturedPath = r.URL.Path
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{}`))
+ }))
+ defer server.Close()
+
+ if _, err := newCapacityForecastTestClient(t, server.URL).CapacityForecast(context.Background()); err != nil {
+ t.Fatalf("CapacityForecast: %v", err)
+ }
+
+ if capturedMethod != http.MethodGet {
+ t.Errorf("expected GET, got %s", capturedMethod)
+ }
+ if !strings.HasPrefix(capturedPath, "/minio/admin/") {
+ t.Errorf("path missing /minio/admin/ prefix: %s", capturedPath)
+ }
+ if !strings.HasSuffix(capturedPath, "/capacity-forecast") {
+ t.Errorf("path missing /capacity-forecast suffix: %s", capturedPath)
+ }
+}
+
+// TestCapacityForecastDecode covers JSON decoding for both nil and non-nil
+// pointer fields, including negative values for thresholds already crossed.
+func TestCapacityForecastDecode(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ wantNil80 bool
+ wantNil100 bool
+ wantVal80 float64
+ wantNegative bool
+ }{
+ {
+ name: "days-until fields omitted produce nil pointers",
+ body: `{"currentUsedBytes":100,"currentTotalBytes":1000,"dailySnapshotCount":7}`,
+ wantNil80: true,
+ wantNil100: true,
+ },
+ {
+ name: "positive days-until values decode as expected",
+ body: `{"daysUntil80Pct":42.5,"daysUntil100Pct":120}`,
+ wantNil80: false,
+ wantVal80: 42.5,
+ },
+ {
+ name: "negative days-until preserved (threshold already crossed)",
+ body: `{"daysUntil80Pct":-3.2,"daysUntil100Pct":-1}`,
+ wantNil80: false,
+ wantVal80: -3.2,
+ wantNegative: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(tc.body))
+ }))
+ defer server.Close()
+
+ f, err := newCapacityForecastTestClient(t, server.URL).CapacityForecast(context.Background())
+ if err != nil {
+ t.Fatalf("CapacityForecast: %v", err)
+ }
+
+ if tc.wantNil80 {
+ if f.DaysUntil80Pct != nil {
+ t.Errorf("expected DaysUntil80Pct nil, got %v", *f.DaysUntil80Pct)
+ }
+ } else {
+ if f.DaysUntil80Pct == nil {
+ t.Fatal("expected DaysUntil80Pct non-nil")
+ }
+ if *f.DaysUntil80Pct != tc.wantVal80 {
+ t.Errorf("DaysUntil80Pct: want %v, got %v", tc.wantVal80, *f.DaysUntil80Pct)
+ }
+ if tc.wantNegative && *f.DaysUntil80Pct >= 0 {
+ t.Errorf("expected negative value, got %v", *f.DaysUntil80Pct)
+ }
+ }
+
+ if tc.wantNil100 && f.DaysUntil100Pct != nil {
+ t.Errorf("expected DaysUntil100Pct nil, got %v", *f.DaysUntil100Pct)
+ }
+ })
+ }
+}
+
+// TestCapacityForecastHTTPError verifies non-200 responses produce an error
+// rather than a zero-value struct.
+func TestCapacityForecastHTTPError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ if _, err := newCapacityForecastTestClient(t, server.URL).CapacityForecast(context.Background()); err == nil {
+ t.Fatal("expected error on 500, got nil")
+ }
+}