This is an automated email from the ASF dual-hosted git repository.

claudevdm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 40df757ea39 Support attributing BigQuery quota and billing to a custom 
GCP project (Python, Java, Go) (#39203)
40df757ea39 is described below

commit 40df757ea39be36ac71beebf849a5a7997ae0ff9
Author: Shahar Epstein <[email protected]>
AuthorDate: Mon Oct 19 06:09:18 2026 +0300

    Support attributing BigQuery quota and billing to a custom GCP project 
(Python, Java, Go) (#39203)
    
    * [Python] Support a custom quota project for BigQuery IO
    
    Add a quota_project_id parameter to ReadFromBigQuery and a
    --quota_project_id pipeline option (GoogleCloudOptions) that attribute
    BigQuery API quota and billing to a project other than the one the data
    resides in.
    
    The quota project is sent as the x-goog-user-project header on the
    apitools BigQuery client, and through client_options on the
    google-cloud-bigquery Client (streaming inserts) and the BigQuery
    Storage Read client. The shared pipeline credentials are left untouched
    instead of being copied with with_quota_project(), which for some
    credential types drops the cached token. The header is only added to
    requests to the BigQuery API hosts: the credentials refresh their token
    through the same Http, and the token endpoint, metadata server and IAM
    Credentials API must not receive it.
    
    It is honored by ReadFromBigQuery (EXPORT and DIRECT_READ, including
    temporary dataset cleanup), ReadAllFromBigQuery, WriteToBigQuery with
    FILE_LOADS or STREAMING_INSERTS, and ReadBigQueryChangeHistory. The
    value is resolved once at expand() time and passed to each DoFn so it
    reaches separately deserialized SDF copies. STORAGE_WRITE_API is
    Java-backed and not covered yet.
    
    Generated-By: Claude Opus 5.5 <[email protected]>
    
    * [Java] Add bigQueryQuotaProjectId pipeline option to BigQueryIO
    
    Add BigQueryOptions.bigQueryQuotaProjectId to attribute BigQuery API
    quota and billing to a project other than the one the data resides in.
    
    For the REST client, the x-goog-user-project header is set by an
    execute interceptor, so it is re-applied on every attempt, including
    after HttpCredentialsAdapter re-initializes headers on a 401 refresh.
    The Storage Read and Write clients set it through setQuotaProjectId.
    
    Generated-By: Claude Opus 5.5 <[email protected]>
    
    * [Go] Add WithQuotaProject option to bigqueryio
    
    Add a WithQuotaProject query option that attributes BigQuery API quota
    and billing to a project other than the one the data resides in, via
    option.WithQuotaProject on the BigQuery client.
    
    Read now accepts query options too. Only WithQuotaProject applies to it;
    UseStandardSQL and WithQueryParameters panic, because Read generates a
    legacy SQL statement without parameters.
    
    Generated-By: Claude Opus 5.5 <[email protected]>
    
    * Add CHANGES.md entry for BigQuery quota project support
    
    Generated-By: Claude Opus 5.5 <[email protected]>
---
 CHANGES.md                                         |   2 +-
 sdks/go/pkg/beam/io/bigqueryio/bigquery.go         |  54 +++-
 sdks/go/pkg/beam/io/bigqueryio/bigquery_test.go    | 125 ++++++++
 .../beam/sdk/io/gcp/bigquery/BigQueryOptions.java  |  10 +
 .../sdk/io/gcp/bigquery/BigQueryServicesImpl.java  |  23 +-
 .../io/gcp/bigquery/BigQueryServicesImplTest.java  |  54 ++++
 sdks/python/apache_beam/io/gcp/bigquery.py         | 121 +++++--
 .../apache_beam/io/gcp/bigquery_change_history.py  |  42 ++-
 .../io/gcp/bigquery_change_history_test.py         |  75 +++++
 .../apache_beam/io/gcp/bigquery_file_loads.py      |  46 ++-
 .../apache_beam/io/gcp/bigquery_file_loads_test.py |  47 +++
 .../apache_beam/io/gcp/bigquery_read_internal.py   |  11 +-
 sdks/python/apache_beam/io/gcp/bigquery_test.py    | 355 +++++++++++++++++++++
 sdks/python/apache_beam/io/gcp/bigquery_tools.py   | 162 +++++++++-
 .../apache_beam/io/gcp/bigquery_tools_test.py      | 232 +++++++++++++-
 .../python/apache_beam/options/pipeline_options.py |  11 +
 16 files changed, 1282 insertions(+), 88 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index d9ca6f0b83d..c10b7262209 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -66,7 +66,7 @@
 
 * Support for X source added (Java/Python) 
([#X](https://github.com/apache/beam/issues/X)).
 * (Java) FirestoreIO: added 
`RpcQosOptions.Builder.withRampupThrottlingDisabled()`, mirroring the existing 
DatastoreIO option, to opt out of the 500/50/5 write ramp-up 
([#27170](https://github.com/apache/beam/issues/27170)).
-
+* Added support for attributing BigQuery API quota and billing to a specific 
GCP project (quota project): `quota_project_id` parameter in `ReadFromBigQuery` 
or `--quota_project_id` pipeline option, honored by the Python BigQuery IO 
transforms (`WriteToBigQuery` with `FILE_LOADS` or `STREAMING_INSERTS`; not yet 
`STORAGE_WRITE_API`, which is Java-backed), `--bigQueryQuotaProjectId` pipeline 
option (Java), and `bigqueryio.WithQuotaProject` read/query option (Go) 
([#37431](https://github.com [...]
 
 ## New Features / Improvements
 
diff --git a/sdks/go/pkg/beam/io/bigqueryio/bigquery.go 
b/sdks/go/pkg/beam/io/bigqueryio/bigquery.go
index bbe951969b9..98818ccf1af 100644
--- a/sdks/go/pkg/beam/io/bigqueryio/bigquery.go
+++ b/sdks/go/pkg/beam/io/bigqueryio/bigquery.go
@@ -34,6 +34,7 @@ import (
        bq "google.golang.org/api/bigquery/v2"
        "google.golang.org/api/googleapi"
        "google.golang.org/api/iterator"
+       "google.golang.org/api/option"
 )
 
 // writeSizeLimit is the maximum number of rows allowed by BQ in a write.
@@ -88,14 +89,22 @@ func NewQualifiedTableName(s string) (QualifiedTableName, 
error) {
 // Read reads all rows from the given table. The table must have a schema
 // compatible with the given type, t, and Read returns a PCollection<t>. If the
 // table has more rows than t, then Read is implicitly a projection.
-func Read(s beam.Scope, project, table string, t reflect.Type) 
beam.PCollection {
+// Of the options, only WithQuotaProject applies to Read; UseStandardSQL and
+// WithQueryParameters panic, because the generated statement is legacy SQL and
+// takes no parameters.
+func Read(s beam.Scope, project, table string, t reflect.Type, options 
...func(*QueryOptions) error) beam.PCollection {
        mustParseTable(table)
 
        s = s.Scope("bigquery.Read")
 
+       qo := applyQueryOptions(options)
+       if qo.UseStandardSQL || len(qo.parameters) > 0 {
+               panic("bigqueryio.Read: UseStandardSQL and WithQueryParameters 
are only supported by Query")
+       }
+
        stmt := constructSelectStatement(t, bigQueryTag, table)
 
-       return query(s, project, stmt, t)
+       return queryWithOptions(s, project, stmt, t, qo)
 }
 
 func constructSelectStatement(t reflect.Type, tagKey string, table string) 
string {
@@ -114,6 +123,9 @@ func constructSelectStatement(t reflect.Type, tagKey 
string, table string) strin
 type QueryOptions struct {
        // UseStandardSQL enables BigQuery's Standard SQL dialect when 
executing a query.
        UseStandardSQL bool
+       // QuotaProject is the GCP project ID used for quota and billing 
attribution
+       // of the BigQuery API calls, if different from the project the data 
resides in.
+       QuotaProject string
        // Parameters are the query parameters for parameterized queries.
        // In the current implementation, user-defines types are not supported 
in Value field.
        // Use *bigquery.QueryParameterValue to build STRUCT/ARRAY parameters
@@ -129,6 +141,17 @@ func UseStandardSQL() func(qo *QueryOptions) error {
        }
 }
 
+// WithQuotaProject sets the GCP project ID used for quota and billing
+// attribution of the BigQuery API calls, if different from the project the
+// data resides in. The credentials used must have the
+// serviceusage.services.use permission on that project.
+func WithQuotaProject(project string) func(qo *QueryOptions) error {
+       return func(qo *QueryOptions) error {
+               qo.QuotaProject = project
+               return nil
+       }
+}
+
 // WithQueryParameters sets the query parameters for parameterized queries.
 // See QueryOptions.parameters for the list of supported Value types.
 func WithQueryParameters(params ...bigquery.QueryParameter) func(qo 
*QueryOptions) error {
@@ -138,6 +161,15 @@ func WithQueryParameters(params 
...bigquery.QueryParameter) func(qo *QueryOption
        }
 }
 
+// clientOptions returns the BigQuery client options implied by qo.
+func clientOptions(qo QueryOptions) []option.ClientOption {
+       var opts []option.ClientOption
+       if qo.QuotaProject != "" {
+               opts = append(opts, option.WithQuotaProject(qo.QuotaProject))
+       }
+       return opts
+}
+
 // Query executes a query. The output must have a schema compatible with the 
given
 // type, t. It returns a PCollection<t>.
 func Query(s beam.Scope, project, q string, t reflect.Type, options 
...func(*QueryOptions) error) beam.PCollection {
@@ -145,15 +177,22 @@ func Query(s beam.Scope, project, q string, t 
reflect.Type, options ...func(*Que
        return query(s, project, q, t, options...)
 }
 
-func query(s beam.Scope, project, query string, t reflect.Type, options 
...func(*QueryOptions) error) beam.PCollection {
-       mustInferSchema(t)
-
+func applyQueryOptions(options []func(*QueryOptions) error) QueryOptions {
        queryOptions := QueryOptions{}
        for _, opt := range options {
                if err := opt(&queryOptions); err != nil {
                        panic(err)
                }
        }
+       return queryOptions
+}
+
+func query(s beam.Scope, project, query string, t reflect.Type, options 
...func(*QueryOptions) error) beam.PCollection {
+       return queryWithOptions(s, project, query, t, 
applyQueryOptions(options))
+}
+
+func queryWithOptions(s beam.Scope, project, query string, t reflect.Type, 
queryOptions QueryOptions) beam.PCollection {
+       mustInferSchema(t)
 
        imp := beam.Impulse(s)
        queryParameters, err := encodeQueryParameters(queryOptions.parameters)
@@ -181,8 +220,11 @@ type queryFn struct {
        Options QueryOptions `json:"options"`
 }
 
+// newClient creates the BigQuery client used by queryFn. Tests replace it.
+var newClient = bigquery.NewClient
+
 func (f *queryFn) ProcessElement(ctx context.Context, _ []byte, emit 
func(beam.X)) error {
-       client, err := bigquery.NewClient(ctx, f.Project)
+       client, err := newClient(ctx, f.Project, clientOptions(f.Options)...)
        if err != nil {
                return err
        }
diff --git a/sdks/go/pkg/beam/io/bigqueryio/bigquery_test.go 
b/sdks/go/pkg/beam/io/bigqueryio/bigquery_test.go
index 9f83eeb847f..4cf0c46b9f6 100644
--- a/sdks/go/pkg/beam/io/bigqueryio/bigquery_test.go
+++ b/sdks/go/pkg/beam/io/bigqueryio/bigquery_test.go
@@ -16,11 +16,16 @@
 package bigqueryio
 
 import (
+       "context"
+       "errors"
+       "fmt"
        "reflect"
+       "strings"
        "testing"
 
        "cloud.google.com/go/bigquery"
        "github.com/apache/beam/sdks/v2/go/pkg/beam"
+       "google.golang.org/api/option"
 )
 
 func TestNewQualifiedTableName(t *testing.T) {
@@ -149,6 +154,88 @@ func Test_mustInferSchema(t *testing.T) {
        }
 }
 
+func TestWithQuotaProject(t *testing.T) {
+       qo := QueryOptions{}
+       if err := WithQuotaProject("quota-project")(&qo); err != nil {
+               t.Fatalf("WithQuotaProject() returned err: %v", err)
+       }
+       if got, want := qo.QuotaProject, "quota-project"; got != want {
+               t.Errorf("qo.QuotaProject = %q, want %q", got, want)
+       }
+}
+
+func TestClientOptions(t *testing.T) {
+       if got := clientOptions(QueryOptions{}); len(got) != 0 {
+               t.Errorf("clientOptions(no quota) = %v, want none", got)
+       }
+       got := clientOptions(QueryOptions{QuotaProject: "quota-project"})
+       if want := 
[]option.ClientOption{option.WithQuotaProject("quota-project")}; 
!reflect.DeepEqual(got, want) {
+               t.Errorf("clientOptions(with quota) = %v, want %v", got, want)
+       }
+}
+
+func TestQueryFn_passesQuotaProjectToClient(t *testing.T) {
+       var gotProject string
+       var gotOpts []option.ClientOption
+       errStop := errors.New("stop")
+       defer func(orig func(context.Context, string, ...option.ClientOption) 
(*bigquery.Client, error)) {
+               newClient = orig
+       }(newClient)
+       newClient = func(_ context.Context, project string, opts 
...option.ClientOption) (*bigquery.Client, error) {
+               gotProject, gotOpts = project, opts
+               return nil, errStop
+       }
+
+       fn := &queryFn{Project: "project", Options: QueryOptions{QuotaProject: 
"quota-project"}}
+       if err := fn.ProcessElement(context.Background(), nil, nil); 
!errors.Is(err, errStop) {
+               t.Fatalf("ProcessElement() err = %v, want %v", err, errStop)
+       }
+       if gotProject != "project" {
+               t.Errorf("client project = %q, want %q", gotProject, "project")
+       }
+       if want := 
[]option.ClientOption{option.WithQuotaProject("quota-project")}; 
!reflect.DeepEqual(gotOpts, want) {
+               t.Errorf("client options = %v, want %v", gotOpts, want)
+       }
+}
+
+// queryFnOf returns the queryFn in pipeline p.
+func queryFnOf(t *testing.T, p *beam.Pipeline) *queryFn {
+       t.Helper()
+       edges, _, err := p.Build()
+       if err != nil {
+               t.Fatalf("Build() failed: %v", err)
+       }
+       for _, e := range edges {
+               if e.DoFn != nil {
+                       if fn, ok := e.DoFn.Recv.(*queryFn); ok {
+                               return fn
+                       }
+               }
+       }
+       t.Fatal("no queryFn in pipeline")
+       return nil
+}
+
+func TestReadAndQuery_passQuotaProjectToQueryFn(t *testing.T) {
+       type quotaSchema struct {
+               Name bigquery.NullString `bigquery:"name"`
+       }
+       beam.RegisterType(reflect.TypeOf((*quotaSchema)(nil)).Elem())
+       typ := reflect.TypeOf(quotaSchema{})
+
+       p, s := beam.NewPipelineWithRoot()
+       Read(s, "project", "project:dataset.table", typ, 
WithQuotaProject("quota-project"))
+       if got := queryFnOf(t, p).Options.QuotaProject; got != "quota-project" {
+               t.Errorf("Read: queryFn.Options.QuotaProject = %q, want %q", 
got, "quota-project")
+       }
+
+       p, s = beam.NewPipelineWithRoot()
+       Query(s, "project", "SELECT 1", typ, WithQuotaProject("quota-project"))
+       if got := queryFnOf(t, p).Options.QuotaProject; got != "quota-project" {
+               t.Errorf("Query: queryFn.Options.QuotaProject = %q, want %q", 
got, "quota-project")
+       }
+}
+
 func TestWithQueryParameters(t *testing.T) {
        t.Run("WithQueryParameters sets parameters correctly", func(t 
*testing.T) {
                params := []bigquery.QueryParameter{
@@ -183,3 +270,41 @@ func TestWithQueryParameters(t *testing.T) {
                }
        })
 }
+
+func TestRead_queryOnlyOptions(t *testing.T) {
+       type readSchema struct {
+               Name bigquery.NullString `bigquery:"name"`
+       }
+       beam.RegisterType(reflect.TypeOf((*readSchema)(nil)).Elem())
+       typ := reflect.TypeOf(readSchema{})
+
+       mustPanic := func(t *testing.T, opt func(*QueryOptions) error) {
+               t.Helper()
+               defer func() {
+                       r := recover()
+                       if r == nil {
+                               t.Errorf("Read() with query-only option does 
not panic")
+                       } else if !strings.Contains(fmt.Sprint(r), "only 
supported by Query") {
+                               t.Errorf("Read() panicked with unexpected 
value: %v", r)
+                       }
+               }()
+               _, s := beam.NewPipelineWithRoot()
+               Read(s, "project", "project:dataset.table", typ, opt)
+       }
+
+       t.Run("UseStandardSQL panics", func(t *testing.T) {
+               mustPanic(t, UseStandardSQL())
+       })
+       t.Run("WithQueryParameters panics", func(t *testing.T) {
+               mustPanic(t, WithQueryParameters(bigquery.QueryParameter{Name: 
"p", Value: 1}))
+       })
+       t.Run("WithQuotaProject does not panic", func(t *testing.T) {
+               defer func() {
+                       if r := recover(); r != nil {
+                               t.Errorf("Read() with WithQuotaProject 
panicked: %v", r)
+                       }
+               }()
+               _, s := beam.NewPipelineWithRoot()
+               Read(s, "project", "project:dataset.table", typ, 
WithQuotaProject("quota-project"))
+       })
+}
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryOptions.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryOptions.java
index 1826f4a1fd2..64382d64bb5 100644
--- 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryOptions.java
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryOptions.java
@@ -197,6 +197,16 @@ public interface BigQueryOptions
 
   void setBigQueryProject(String value);
 
+  @Description(
+      "GCP project ID used for quota and billing attribution of BigQuery API 
requests "
+          + "(sets the X-Goog-User-Project header), if different from the 
project the data "
+          + "resides in. If unspecified, the project associated with the 
credentials is used. "
+          + "The credentials used must have the serviceusage.services.use 
permission on this "
+          + "project.")
+  String getBigQueryQuotaProjectId();
+
+  void setBigQueryQuotaProjectId(String value);
+
   @Description("Maximum (best effort) size of a single append to the storage 
API.")
   @Default.Integer(2 * 1024 * 1024)
   Integer getStorageApiAppendThresholdBytes();
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java
index 7a924c6aa02..a921be3700e 100644
--- 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java
@@ -1800,7 +1800,8 @@ public class BigQueryServicesImpl implements 
BigQueryServices {
   }
 
   /** Returns a BigQuery client builder using the specified {@link 
BigQueryOptions}. */
-  private static Bigquery.Builder newBigQueryClient(BigQueryOptions options) {
+  @VisibleForTesting
+  static Bigquery.Builder newBigQueryClient(BigQueryOptions options) {
     // Do not log 404. It clutters the output and is possibly even required by 
the
     // caller.
     RetryHttpRequestInitializer httpRequestInitializer =
@@ -1818,6 +1819,18 @@ public class BigQueryServicesImpl implements 
BigQueryServices {
     initBuilder.add(new 
LatencyRecordingHttpRequestInitializer(API_METRIC_LABEL));
 
     initBuilder.add(httpRequestInitializer);
+    // Set the quota project as a request header instead of deriving 
credentials: a derived
+    // credential inherits the shared credential's cached request metadata, 
which lacks
+    // x-goog-user-project until the next token refresh. Applied by an execute 
interceptor so it
+    // is re-applied on every attempt; HttpCredentialsAdapter re-initializes 
the request headers
+    // from the credential after a 401 refresh, which would otherwise override 
it.
+    @Nullable String quotaProjectId = options.getBigQueryQuotaProjectId();
+    if (!Strings.isNullOrEmpty(quotaProjectId)) {
+      initBuilder.add(
+          request ->
+              request.setInterceptor(
+                  r -> r.getHeaders().set("x-goog-user-project", 
quotaProjectId)));
+    }
     HttpRequestInitializer chainInitializer =
         new ChainingHttpRequestInitializer(
             Iterables.toArray(initBuilder.build(), 
HttpRequestInitializer.class));
@@ -1848,6 +1861,10 @@ public class BigQueryServicesImpl implements 
BigQueryServices {
       if (!Strings.isNullOrEmpty(endpoint)) {
         builder.setEndpoint(trimSchemaIfNecessary(endpoint));
       }
+      @Nullable String quotaProjectId = options.getBigQueryQuotaProjectId();
+      if (!Strings.isNullOrEmpty(quotaProjectId)) {
+        builder.setQuotaProjectId(quotaProjectId);
+      }
       return BigQueryWriteClient.create(
           builder
               .setCredentialsProvider(() -> 
options.as(GcpOptions.class).getGcpCredential())
@@ -1973,6 +1990,10 @@ public class BigQueryServicesImpl implements 
BigQueryServices {
       if (!Strings.isNullOrEmpty(endpoint)) {
         settingsBuilder.setEndpoint(trimSchemaIfNecessary(endpoint));
       }
+      @Nullable String quotaProjectId = options.getBigQueryQuotaProjectId();
+      if (!Strings.isNullOrEmpty(quotaProjectId)) {
+        settingsBuilder.setQuotaProjectId(quotaProjectId);
+      }
 
       UnaryCallSettings.Builder<CreateReadSessionRequest, ReadSession> 
createReadSessionSettings =
           settingsBuilder.getStubSettingsBuilder().createReadSessionSettings();
diff --git 
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java
 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java
index 90b2e1fb872..642d35b4390 100644
--- 
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java
+++ 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java
@@ -41,6 +41,8 @@ import com.google.api.client.googleapis.json.GoogleJsonError;
 import com.google.api.client.googleapis.json.GoogleJsonError.ErrorInfo;
 import com.google.api.client.googleapis.json.GoogleJsonErrorContainer;
 import com.google.api.client.googleapis.json.GoogleJsonResponseException;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpResponseException;
 import com.google.api.client.http.LowLevelHttpResponse;
 import com.google.api.client.json.GenericJson;
@@ -2311,6 +2313,58 @@ public class BigQueryServicesImplTest {
     assertEquals(123456, (long) 
container.getCounter(metricName).getCumulative());
   }
 
+  @Test
+  public void testQuotaProjectIdOverrides() throws IOException {
+    BigQueryOptions options = 
PipelineOptionsFactory.create().as(BigQueryOptions.class);
+    options.setBigQueryQuotaProjectId("my-quota-project");
+
+    assertEquals(
+        "my-quota-project",
+        new BigQueryServicesImpl.StorageClientImpl(options)
+            .getClient()
+            .getSettings()
+            .getQuotaProjectId());
+    assertEquals(
+        "my-quota-project",
+        new BigQueryServicesImpl.WriteStreamServiceImpl(options)
+            .getClient()
+            .getSettings()
+            .getQuotaProjectId());
+  }
+
+  @Test
+  public void testQuotaProjectIdSetsRequestHeader() throws IOException {
+    BigQueryOptions options = 
PipelineOptionsFactory.create().as(BigQueryOptions.class);
+    options.setGcpCredential(null);
+    GenericUrl url = new 
GenericUrl("https://bigquery.googleapis.com/bigquery/v2/projects";);
+
+    HttpRequest request =
+        BigQueryServicesImpl.newBigQueryClient(options)
+            .build()
+            .getRequestFactory()
+            .buildGetRequest(url);
+    request.getInterceptor().intercept(request);
+    
assertNull(request.getHeaders().getFirstHeaderStringValue("x-goog-user-project"));
+
+    options.setBigQueryQuotaProjectId("my-quota-project");
+    request =
+        BigQueryServicesImpl.newBigQueryClient(options)
+            .build()
+            .getRequestFactory()
+            .buildGetRequest(url);
+    // The interceptor runs before every attempt, including retries.
+    request.getInterceptor().intercept(request);
+    assertEquals(
+        "my-quota-project", 
request.getHeaders().getFirstHeaderStringValue("x-goog-user-project"));
+
+    // Simulate HttpCredentialsAdapter re-initializing headers from a 
credential that carries
+    // its own quota project after a 401 refresh: the configured value must 
win on the retry.
+    request.getHeaders().set("x-goog-user-project", "credential-project");
+    request.getInterceptor().intercept(request);
+    assertEquals(
+        "my-quota-project", 
request.getHeaders().getFirstHeaderStringValue("x-goog-user-project"));
+  }
+
   @Test
   public void testEndpointOverrides() throws IOException {
     BigQueryOptions options = 
PipelineOptionsFactory.create().as(BigQueryOptions.class);
diff --git a/sdks/python/apache_beam/io/gcp/bigquery.py 
b/sdks/python/apache_beam/io/gcp/bigquery.py
index 2a52b31edf7..f3d64bfcfa6 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery.py
@@ -720,7 +720,8 @@ class _CustomBigQuerySource(BoundedSource):
       step_name=None,
       unique_id=None,
       temp_dataset=None,
-      query_priority=BigQueryQueryPriority.BATCH):
+      query_priority=BigQueryQueryPriority.BATCH,
+      quota_project_id=None):
     if table is not None and query is not None:
       raise ValueError(
           'Both a BigQuery table and a query were specified.'
@@ -754,6 +755,7 @@ class _CustomBigQuerySource(BoundedSource):
     self.use_json_exports = use_json_exports
     self.temp_dataset = temp_dataset
     self.query_priority = query_priority
+    self.quota_project_id = quota_project_id
     self._job_name = job_name or 'BQ_EXPORT_JOB'
     self._step_name = step_name
     self._source_uuid = unique_id
@@ -773,12 +775,15 @@ class _CustomBigQuerySource(BoundedSource):
         'use_legacy_sql': self.use_legacy_sql,
         'bigquery_job_labels': json.dumps(self.bigquery_job_labels),
         'export_file_format': export_format,
+        'quota_project_id': DisplayDataItem(
+            self._get_quota_project_id(),
+            label='BigQuery Quota Project').drop_if_none(),
         'launchesBigQueryJobs': DisplayDataItem(
             True, label="This Dataflow job launches bigquery jobs."),
     }
 
   def estimate_size(self):
-    bq = bigquery_tools.BigQueryWrapper.from_pipeline_options(self.options)
+    bq = self._create_bq_wrapper()
     if self.table_reference is not None:
       table_ref = self.table_reference
       if (isinstance(self.table_reference, vp.ValueProvider) and
@@ -844,6 +849,23 @@ class _CustomBigQuerySource(BoundedSource):
       project = self.project
     return project
 
+  def _get_quota_project_id(self):
+    """Returns the explicit quota project ID, else the pipeline option."""
+    quota_project_id = bigquery_tools._quota_project_id_from_options(
+        self.options)
+    return self.quota_project_id or quota_project_id
+
+  def _create_bq_wrapper(self, **kwargs):
+    """Creates a BigQueryWrapper for the API calls made by this source.
+
+    Every BigQuery client this source uses is built here, so that quota
+    attribution cannot be forgotten at an individual call site.
+    """
+    return bigquery_tools.BigQueryWrapper(
+        pipeline_options=self.options,
+        quota_project_id=self.quota_project_id,
+        **kwargs)
+
   def _create_source(self, path, coder):
     if not self.use_json_exports:
       return create_avro_source(path, validate=self.validate)
@@ -858,10 +880,9 @@ class _CustomBigQuerySource(BoundedSource):
 
   def split(self, desired_bundle_size, start_position=None, 
stop_position=None):
     if self.export_result is None:
-      bq = bigquery_tools.BigQueryWrapper(
+      bq = self._create_bq_wrapper(
           temp_dataset_id=(
-              self.temp_dataset.datasetId if self.temp_dataset else None),
-          client=bigquery_tools.BigQueryWrapper._bigquery_client(self.options))
+              self.temp_dataset.datasetId if self.temp_dataset else None))
 
       if self.query is not None:
         self._setup_temporary_dataset(bq)
@@ -1051,7 +1072,8 @@ class _CustomBigQueryStorageSource(BoundedSource):
       temp_dataset: Optional[DatasetReference] = None,
       temp_table: Optional[TableReference] = None,
       use_native_datetime: Optional[bool] = False,
-      timeout: Optional[float] = None):
+      timeout: Optional[float] = None,
+      quota_project_id: Optional[str] = None):
 
     if table is not None and query is not None:
       raise ValueError(
@@ -1090,6 +1112,7 @@ class _CustomBigQueryStorageSource(BoundedSource):
     self._job_name = job_name or 'BQ_DIRECT_READ_JOB'
     self._step_name = step_name
     self._source_uuid = unique_id
+    self.quota_project_id = quota_project_id
 
   def _get_project(self):
     """Returns the project that queries and exports will be billed to."""
@@ -1101,6 +1124,23 @@ class _CustomBigQueryStorageSource(BoundedSource):
         return project
     return self.project
 
+  def _get_quota_project_id(self):
+    """Returns the explicit quota project ID, else the pipeline option."""
+    quota_project_id = bigquery_tools._quota_project_id_from_options(
+        self.pipeline_options)
+    return self.quota_project_id or quota_project_id
+
+  def _create_bq_wrapper(self, **kwargs):
+    """Creates a BigQueryWrapper for the API calls made by this source.
+
+    Every BigQuery client this source uses is built here, so that quota
+    attribution cannot be forgotten at an individual call site.
+    """
+    return bigquery_tools.BigQueryWrapper(
+        pipeline_options=self.pipeline_options,
+        quota_project_id=self.quota_project_id,
+        **kwargs)
+
   def _get_parent_project(self):
     """Returns the project that will be billed."""
     if self.temp_table:
@@ -1188,14 +1228,16 @@ class _CustomBigQueryStorageSource(BoundedSource):
         'use_native_datetime': self.use_native_datetime,
         'selected_fields': str(self.selected_fields),
         'row_restriction': str(self.row_restriction),
+        'quota_project_id': DisplayDataItem(
+            self._get_quota_project_id(),
+            label='BigQuery Quota Project').drop_if_none(),
         'launchesBigQueryJobs': DisplayDataItem(
             True, label="This Dataflow job launches bigquery jobs."),
     }
 
   def estimate_size(self):
     # Returns the pre-filtering size of the (temporary) table being read.
-    bq = bigquery_tools.BigQueryWrapper.from_pipeline_options(
-        self.pipeline_options)
+    bq = self._create_bq_wrapper()
     if self.table_reference is not None:
       table_ref = self.table_reference
       if (isinstance(self.table_reference, vp.ValueProvider) and
@@ -1242,10 +1284,8 @@ class _CustomBigQueryStorageSource(BoundedSource):
 
   def split(self, desired_bundle_size, start_position=None, 
stop_position=None):
     if self.split_result is None:
-      bq = bigquery_tools.BigQueryWrapper(
-          temp_table_ref=(self.temp_table if self.temp_table else None),
-          client=bigquery_tools.BigQueryWrapper._bigquery_client(
-              self.pipeline_options))
+      bq = self._create_bq_wrapper(
+          temp_table_ref=(self.temp_table if self.temp_table else None))
 
       if self.query is not None:
         self._setup_temporary_dataset(bq)
@@ -1278,7 +1318,8 @@ class _CustomBigQueryStorageSource(BoundedSource):
       if self.row_restriction is not None:
         requested_session.read_options.row_restriction = self.row_restriction
 
-      storage_client = bq_storage.BigQueryReadClient()
+      storage_client = bigquery_tools.create_bq_storage_client(
+          self._get_quota_project_id())
       stream_count = self._get_stream_count(bq, desired_bundle_size)
 
       parent = 'projects/{}'.format(self.table_reference.projectId)
@@ -1302,8 +1343,10 @@ class _CustomBigQueryStorageSource(BoundedSource):
 
       self.split_result = [
           _CustomBigQueryStorageStreamSource(
-              stream.name, self.use_native_datetime, self.timeout)
-          for stream in read_session.streams
+              stream.name,
+              self.use_native_datetime,
+              self.timeout,
+              self._get_quota_project_id()) for stream in read_session.streams
       ]
 
     for source in self.split_result:
@@ -1337,10 +1380,12 @@ class _CustomBigQueryStorageStreamSource(BoundedSource):
       self,
       read_stream_name: str,
       use_native_datetime: Optional[bool] = True,
-      timeout: Optional[float] = None):
+      timeout: Optional[float] = None,
+      quota_project_id: Optional[str] = None):
     self.read_stream_name = read_stream_name
     self.use_native_datetime = use_native_datetime
     self.timeout = timeout
+    self.quota_project_id = quota_project_id
 
   def display_data(self):
     return {
@@ -1363,7 +1408,10 @@ class _CustomBigQueryStorageStreamSource(BoundedSource):
     return SourceBundle(
         weight=1.0,
         source=_CustomBigQueryStorageStreamSource(
-            self.read_stream_name, self.use_native_datetime),
+            self.read_stream_name,
+            self.use_native_datetime,
+            self.timeout,
+            self.quota_project_id),
         start_position=None,
         stop_position=None)
 
@@ -1399,7 +1447,8 @@ class _CustomBigQueryStorageStreamSource(BoundedSource):
 
   def read_arrow(self):
 
-    storage_client = bq_storage.BigQueryReadClient()
+    storage_client = bigquery_tools.create_bq_storage_client(
+        self.quota_project_id)
     read_rows_kwargs = {'retry_delay_callback': self.retry_delay_callback}
     if self.timeout is not None:
       read_rows_kwargs['timeout'] = self.timeout
@@ -1418,7 +1467,8 @@ class _CustomBigQueryStorageStreamSource(BoundedSource):
       yield py_row
 
   def read_avro(self):
-    storage_client = bq_storage.BigQueryReadClient()
+    storage_client = bigquery_tools.create_bq_storage_client(
+        self.quota_project_id)
     read_rows_kwargs = {'retry_delay_callback': self.retry_delay_callback}
     if self.timeout is not None:
       read_rows_kwargs['timeout'] = self.timeout
@@ -1499,7 +1549,8 @@ class BigQueryWriteFn(DoFn):
       with_batched_input=False,
       ignore_unknown_columns=False,
       max_retries=MAX_INSERT_RETRIES,
-      max_insert_payload_size=MAX_INSERT_PAYLOAD_SIZE):
+      max_insert_payload_size=MAX_INSERT_PAYLOAD_SIZE,
+      quota_project_id=None):
     """Initialize a WriteToBigQuery transform.
 
     Args:
@@ -1552,9 +1603,12 @@ class BigQueryWriteFn(DoFn):
         backoffs (effectively retry forever).
       max_insert_payload_size: The maximum byte size for a BigQuery legacy
         streaming insert payload.
+      quota_project_id: Optional GCP project ID to use for quota and billing of
+        BigQuery API requests.
     """
     self.schema = schema
     self.test_client = test_client
+    self.quota_project_id = quota_project_id
     self.create_disposition = create_disposition
     self.write_disposition = write_disposition
     if write_disposition in (BigQueryDisposition.WRITE_EMPTY,
@@ -1633,7 +1687,7 @@ class BigQueryWriteFn(DoFn):
 
     if not self.bigquery_wrapper:
       self.bigquery_wrapper = bigquery_tools.BigQueryWrapper(
-          client=self.test_client)
+          client=self.test_client, quota_project_id=self.quota_project_id)
 
     (
         bigquery_tools.BigQueryWrapper.HISTOGRAM_METRIC_LOGGER.
@@ -1958,6 +2012,8 @@ class _StreamToBigQuery(PTransform):
       yield (key, (value, insert_id))
 
   def expand(self, input):
+    quota_project_id = bigquery_tools._quota_project_id_from_options(
+        input.pipeline.options)
     bigquery_write_fn = BigQueryWriteFn(
         schema=self.schema,
         batch_size=self.batch_size,
@@ -1971,7 +2027,8 @@ class _StreamToBigQuery(PTransform):
         ignore_unknown_columns=self.ignore_unknown_columns,
         with_batched_input=self.with_auto_sharding,
         max_retries=self._max_retries,
-        max_insert_payload_size=self._max_insert_payload_size)
+        max_insert_payload_size=self._max_insert_payload_size,
+        quota_project_id=quota_project_id)
 
     def _add_random_shard(element):
       key = element[0]
@@ -3043,6 +3100,12 @@ class ReadFromBigQuery(PTransform):
       PCollection with a schema and yielding Beam Rows via the option
       `BEAM_ROW`. For more information on schemas, see
       
https://beam.apache.org/documentation/programming-guide/#what-is-a-schema)
+    quota_project_id (str): The GCP project ID to use for quota and billing
+      of BigQuery API requests issued by this transform, if different from
+      the project the data resides in. Falls back to the
+      ``--quota_project_id`` pipeline option if not set. The credentials
+      used must have the ``serviceusage.services.use`` permission on that
+      project.
     query_output_schema: Required when output_type is 'BEAM_ROW' and a query
       is specified. A BigQuery schema describing the query result columns,
       since the schema cannot be auto-derived from an existing table when
@@ -3143,10 +3206,12 @@ class ReadFromBigQuery(PTransform):
             '%s: table must be of type string'
             '; got a callable instead' % self.__class__.__name__)
       return output_pcollection | bigquery_schema_tools.convert_to_usertype(
-          bigquery_tools.BigQueryWrapper().get_table(
-              project_id=table_details.projectId,
-              dataset_id=table_details.datasetId,
-              table_id=table_details.tableId).schema,
+          bigquery_tools.BigQueryWrapper(
+              pipeline_options=output_pcollection.pipeline.options,
+              quota_project_id=self._kwargs.get('quota_project_id')).get_table(
+                  project_id=table_details.projectId,
+                  dataset_id=table_details.datasetId,
+                  table_id=table_details.tableId).schema,
           self._kwargs.get('selected_fields', None))
     else:
       raise ValueError(
@@ -3228,7 +3293,9 @@ class ReadFromBigQuery(PTransform):
                 bigquery_dataset_labels=self.bigquery_dataset_labels,
                 *self._args,
                 **self._kwargs))
-        | _PassThroughThenCleanupTempDatasets(project_to_cleanup_pcoll))
+        | _PassThroughThenCleanupTempDatasets(
+            project_to_cleanup_pcoll,
+            quota_project_id=self._kwargs.get('quota_project_id')))
 
 
 class ReadFromBigQueryRequest:
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_change_history.py 
b/sdks/python/apache_beam/io/gcp/bigquery_change_history.py
index 1e90c297477..9f836190d9c 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_change_history.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_change_history.py
@@ -416,7 +416,8 @@ class _PollChangeHistoryFn(beam.DoFn, 
beam.transforms.core.RestrictionProvider):
       start_time: Timestamp,
       stop_time: Timestamp,
       poll_interval: Duration,
-      location: Optional[str] = None) -> None:
+      location: Optional[str] = None,
+      quota_project_id: Optional[str] = None) -> None:
     self._table = table
     self._project = project
     self._change_function = change_function
@@ -425,9 +426,11 @@ class _PollChangeHistoryFn(beam.DoFn, 
beam.transforms.core.RestrictionProvider):
     self._stop_time = stop_time
     self._poll_interval = poll_interval
     self._location = location
+    self._quota_project_id = quota_project_id
 
   def setup(self) -> None:
-    self._bq_wrapper = bigquery_tools.BigQueryWrapper()
+    self._bq_wrapper = bigquery_tools.BigQueryWrapper(
+        quota_project_id=self._quota_project_id)
     if self._location is None:
       table_ref = bigquery_tools.parse_table_reference(
           self._table, project=self._project)
@@ -570,7 +573,8 @@ class _ExecuteQueryFn(beam.DoFn):
       change_type_column: str = 'change_type',
       change_timestamp_column: str = 'change_timestamp',
       columns: Optional[list[str]] = None,
-      row_filter: Optional[str] = None) -> None:
+      row_filter: Optional[str] = None,
+      quota_project_id: Optional[str] = None) -> None:
     self._table = table
     self._project = project
     self._change_function = change_function
@@ -580,9 +584,11 @@ class _ExecuteQueryFn(beam.DoFn):
     self._change_timestamp_column = change_timestamp_column
     self._columns = columns
     self._row_filter = row_filter
+    self._quota_project_id = quota_project_id
 
   def setup(self) -> None:
-    self._bq_wrapper = bigquery_tools.BigQueryWrapper()
+    self._bq_wrapper = bigquery_tools.BigQueryWrapper(
+        quota_project_id=self._quota_project_id)
     if self._location is None:
       table_ref = bigquery_tools.parse_table_reference(
           self._table, project=self._project)
@@ -711,11 +717,13 @@ class _ReadStorageStreamsSDF(beam.DoFn,
       batch_arrow_read: bool = True,
       change_timestamp_column: str = 'change_timestamp',
       max_split_rounds: int = 1,
-      emit_raw_batches: bool = False) -> None:
+      emit_raw_batches: bool = False,
+      quota_project_id: Optional[str] = None) -> None:
     self._batch_arrow_read = batch_arrow_read
     self._change_timestamp_column = change_timestamp_column
     self._max_split_rounds = max_split_rounds
     self._emit_raw_batches = emit_raw_batches
+    self._quota_project_id = quota_project_id
     self._storage_client = None
 
   def _ensure_client(self) -> None:
@@ -727,7 +735,8 @@ class _ReadStorageStreamsSDF(beam.DoFn,
     """
     if self._storage_client is None:
       _LOGGER.info('[Read] creating BigQueryReadClient')
-      self._storage_client = bq_storage.BigQueryReadClient()
+      self._storage_client = bigquery_tools.create_bq_storage_client(
+          self._quota_project_id)
 
   def setup(self) -> None:
     self._ensure_client()
@@ -1072,9 +1081,13 @@ class _CleanupTempTablesFn(beam.DoFn):
   STREAMS_READ = beam.transforms.userstate.CombiningValueStateSpec(
       'streams_read', sum)
 
+  def __init__(self, quota_project_id: Optional[str] = None) -> None:
+    self._quota_project_id = quota_project_id
+
   def setup(self) -> None:
     _LOGGER.info('[Cleanup] setup: creating BigQueryWrapper')
-    self._bq_wrapper = bigquery_tools.BigQueryWrapper()
+    self._bq_wrapper = bigquery_tools.BigQueryWrapper(
+        quota_project_id=self._quota_project_id)
 
   def process(
       self,
@@ -1247,6 +1260,9 @@ class ReadBigQueryChangeHistory(beam.PTransform):
           'project must be specified either in ReadBigQueryChangeHistory '
           'or in pipeline options (--project)')
 
+    quota_project_id = bigquery_tools._quota_project_id_from_options(
+        pbegin.pipeline.options)
+
     start_time = Timestamp(self._start_time or time.time())
     stop_time = (
         Timestamp(self._stop_time)
@@ -1290,7 +1306,8 @@ class ReadBigQueryChangeHistory(beam.PTransform):
                 start_time=start_time,
                 stop_time=stop_time,
                 poll_interval=poll_interval,
-                location=self._location)))
+                location=self._location,
+                quota_project_id=quota_project_id)))
 
     # CommitQueryResults: Reshuffle commits _QueryResult (temp table ref)
     # so that if the Read SDF retries, it re-reads the existing temp table
@@ -1311,7 +1328,8 @@ class ReadBigQueryChangeHistory(beam.PTransform):
                 change_type_column=self._change_type_column,
                 change_timestamp_column=self._change_timestamp_column,
                 columns=self._columns,
-                row_filter=self._row_filter))
+                row_filter=self._row_filter,
+                quota_project_id=quota_project_id))
         | 'CommitQueryResults' >> beam.Reshuffle())
 
     emit_raw = self._reshuffle_decompress
@@ -1321,7 +1339,8 @@ class ReadBigQueryChangeHistory(beam.PTransform):
             batch_arrow_read=self._batch_arrow_read,
             change_timestamp_column=self._change_timestamp_column,
             max_split_rounds=self._max_split_rounds,
-            emit_raw_batches=emit_raw))
+            emit_raw_batches=emit_raw,
+            quota_project_id=quota_project_id))
     if emit_raw:
       read_sdf = read_sdf.with_output_types(tuple[bytes, bytes])
     else:
@@ -1334,7 +1353,8 @@ class ReadBigQueryChangeHistory(beam.PTransform):
 
     _ = (
         read_outputs[_CLEANUP_TAG]
-        | 'CleanupTempTables' >> beam.ParDo(_CleanupTempTablesFn()))
+        | 'CleanupTempTables' >> beam.ParDo(
+            _CleanupTempTablesFn(quota_project_id=quota_project_id)))
 
     if emit_raw:
       # Reshuffle raw Arrow batches for fan-out, then decompress and
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_change_history_test.py 
b/sdks/python/apache_beam/io/gcp/bigquery_change_history_test.py
index 11b467f26d4..e1b1eb69ae3 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_change_history_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_change_history_test.py
@@ -23,12 +23,18 @@ Tests:
   - ReadBigQueryChangeHistory validation
 """
 
+import contextlib
 import datetime
 import unittest
+from unittest import mock
 
+from apache_beam.io.gcp import bigquery_change_history as bqch
+from apache_beam.io.gcp import bigquery_tools
 from apache_beam.io.gcp.bigquery_change_history import 
ReadBigQueryChangeHistory
 from apache_beam.io.gcp.bigquery_change_history import build_changes_query
 from apache_beam.io.gcp.bigquery_change_history import compute_ranges
+from apache_beam.options.pipeline_options import PipelineOptions
+from apache_beam.testing.test_pipeline import TestPipeline
 from apache_beam.utils.timestamp import Duration
 from apache_beam.utils.timestamp import Timestamp
 
@@ -205,5 +211,74 @@ class ValidationTest(unittest.TestCase):
     self.assertEqual(t._buffer_sec, 10)
 
 
[email protected](HttpError is None, 'GCP dependencies are not installed')
+class QuotaProjectTest(unittest.TestCase):
+  """Tests that the quota project reaches every BigQuery client."""
+  _QUOTA = 'my-billing-project'
+
+  def _assert_wrapper_quota(self, fn):
+    with mock.patch.object(bigquery_tools, 'BigQueryWrapper') as mock_wrapper:
+      fn.setup()
+    self.assertEqual(
+        mock_wrapper.call_args.kwargs['quota_project_id'], self._QUOTA)
+
+  def test_poll_fn_setup_passes_quota_project(self):
+    fn = bqch._PollChangeHistoryFn(
+        table='proj:dataset.table',
+        project='p',
+        change_function='APPENDS',
+        buffer=Duration(seconds=10),
+        start_time=Timestamp(0),
+        stop_time=Timestamp(100),
+        poll_interval=Duration(seconds=60),
+        quota_project_id=self._QUOTA)
+    self._assert_wrapper_quota(fn)
+
+  def test_execute_query_fn_setup_passes_quota_project(self):
+    fn = bqch._ExecuteQueryFn(
+        table='proj:dataset.table',
+        project='p',
+        change_function='APPENDS',
+        temp_dataset='tmp',
+        location='US',
+        quota_project_id=self._QUOTA)
+    self._assert_wrapper_quota(fn)
+
+  def test_cleanup_fn_setup_passes_quota_project(self):
+    self._assert_wrapper_quota(bqch._CleanupTempTablesFn(self._QUOTA))
+
+  def test_read_sdf_creates_storage_client_with_quota_project(self):
+    fn = bqch._ReadStorageStreamsSDF(quota_project_id=self._QUOTA)
+    with mock.patch.object(bigquery_tools,
+                           'create_bq_storage_client') as mock_create:
+      fn.setup()
+    mock_create.assert_called_once_with(self._QUOTA)
+
+  def test_expand_resolves_quota_project_from_options(self):
+    options = PipelineOptions(
+        ['--quota_project_id=' + self._QUOTA, '--no_pipeline_type_check'])
+    classes = [
+        '_PollChangeHistoryFn',
+        '_ExecuteQueryFn',
+        '_ReadStorageStreamsSDF',
+        '_CleanupTempTablesFn'
+    ]
+    p = TestPipeline(options=options)
+    with contextlib.ExitStack() as stack:
+      mocks = [
+          stack.enter_context(
+              mock.patch.object(
+                  getattr(bqch, name),
+                  '__init__',
+                  autospec=True,
+                  side_effect=getattr(bqch, name).__init__)) for name in 
classes
+      ]
+      _ = p | ReadBigQueryChangeHistory(
+          table='p:d.t', project='p', start_time=0, temp_dataset='tmp')
+    for name, m in zip(classes, mocks):
+      self.assertEqual(
+          m.call_args.kwargs['quota_project_id'], self._QUOTA, name)
+
+
 if __name__ == '__main__':
   unittest.main()
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py 
b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py
index dabe80c5c27..79940d109f0 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py
@@ -383,16 +383,19 @@ class UpdateDestinationSchema(beam.DoFn):
       test_client=None,
       additional_bq_parameters=None,
       step_name=None,
-      load_job_project_id=None):
+      load_job_project_id=None,
+      quota_project_id=None):
     self.project = project
     self._test_client = test_client
+    self._quota_project_id = quota_project_id
     self._write_disposition = write_disposition
     self._additional_bq_parameters = additional_bq_parameters or {}
     self._step_name = step_name
     self._load_job_project_id = load_job_project_id
 
   def start_bundle(self):
-    self.bq_wrapper = bigquery_tools.BigQueryWrapper(client=self._test_client)
+    self.bq_wrapper = bigquery_tools.BigQueryWrapper(
+        client=self._test_client, quota_project_id=self._quota_project_id)
     self._bq_io_metadata = create_bigquery_io_metadata(self._step_name)
     self.pending_jobs = []
 
@@ -525,11 +528,13 @@ class TriggerCopyJobs(beam.DoFn):
       write_disposition=None,
       test_client=None,
       step_name=None,
-      load_job_project_id=None):
+      load_job_project_id=None,
+      quota_project_id=None):
     self.project = project
     self.create_disposition = create_disposition
     self.write_disposition = write_disposition
     self.test_client = test_client
+    self.quota_project_id = quota_project_id
     self._observed_tables = set()
     self.bq_io_metadata = None
     self._step_name = step_name
@@ -545,7 +550,8 @@ class TriggerCopyJobs(beam.DoFn):
     self._observed_tables = set()
 
   def start_bundle(self):
-    self.bq_wrapper = bigquery_tools.BigQueryWrapper(client=self.test_client)
+    self.bq_wrapper = bigquery_tools.BigQueryWrapper(
+        client=self.test_client, quota_project_id=self.quota_project_id)
     if not self.bq_io_metadata:
       self.bq_io_metadata = create_bigquery_io_metadata(self._step_name)
     self.pending_jobs = []
@@ -677,10 +683,12 @@ class TriggerLoadJobs(beam.DoFn):
       additional_bq_parameters=None,
       source_format=None,
       step_name=None,
-      load_job_project_id=None):
+      load_job_project_id=None,
+      quota_project_id=None):
     self.schema = schema
     self.project = project
     self.test_client = test_client
+    self.quota_project_id = quota_project_id
     self.temporary_tables = temporary_tables
     self.additional_bq_parameters = additional_bq_parameters or {}
     self.source_format = source_format
@@ -709,7 +717,8 @@ class TriggerLoadJobs(beam.DoFn):
     return result
 
   def start_bundle(self):
-    self.bq_wrapper = bigquery_tools.BigQueryWrapper(client=self.test_client)
+    self.bq_wrapper = bigquery_tools.BigQueryWrapper(
+        client=self.test_client, quota_project_id=self.quota_project_id)
     if not self.bq_io_metadata:
       self.bq_io_metadata = create_bigquery_io_metadata(self._step_name)
     self.pending_jobs = []
@@ -928,11 +937,13 @@ class PartitionFiles(beam.DoFn):
 
 
 class DeleteTablesFn(beam.DoFn):
-  def __init__(self, test_client=None):
+  def __init__(self, test_client=None, quota_project_id=None):
     self.test_client = test_client
+    self.quota_project_id = quota_project_id
 
   def start_bundle(self):
-    self.bq_wrapper = bigquery_tools.BigQueryWrapper(client=self.test_client)
+    self.bq_wrapper = bigquery_tools.BigQueryWrapper(
+        client=self.test_client, quota_project_id=self.quota_project_id)
 
   def process(self, table_reference):
     _LOGGER.info("Deleting table %s", table_reference)
@@ -998,6 +1009,7 @@ class BigQueryBatchFileLoads(beam.PTransform):
 
     self.test_client = test_client
     self.schema = schema
+    self.quota_project_id = None
     self._temp_file_format = temp_file_format or bigquery_tools.FileFormat.JSON
 
     # If we have multiple destinations, then we will have multiple load jobs,
@@ -1207,7 +1219,8 @@ class BigQueryBatchFileLoads(beam.PTransform):
                 additional_bq_parameters=self.additional_bq_parameters,
                 source_format=self._temp_file_format,
                 step_name=step_name,
-                load_job_project_id=self.load_job_project_id),
+                load_job_project_id=self.load_job_project_id,
+                quota_project_id=self.quota_project_id),
             load_job_name_pcv,
             *self.schema_side_inputs).with_outputs(
                 TriggerLoadJobs.TEMP_TABLES,
@@ -1228,7 +1241,8 @@ class BigQueryBatchFileLoads(beam.PTransform):
                 test_client=self.test_client,
                 additional_bq_parameters=self.additional_bq_parameters,
                 step_name=step_name,
-                load_job_project_id=self.load_job_project_id),
+                load_job_project_id=self.load_job_project_id,
+                quota_project_id=self.quota_project_id),
             schema_mod_job_name_pcv))
 
     if self.write_disposition in ('WRITE_EMPTY', 'WRITE_TRUNCATE'):
@@ -1259,7 +1273,8 @@ class BigQueryBatchFileLoads(beam.PTransform):
                 write_disposition=self.write_disposition,
                 test_client=self.test_client,
                 step_name=step_name,
-                load_job_project_id=self.load_job_project_id),
+                load_job_project_id=self.load_job_project_id,
+                quota_project_id=self.quota_project_id),
             copy_job_name_pcv,
             pvalue.AsIter(schema_mod_job_ids_pc)).with_outputs(
                 TriggerCopyJobs.TRIGGER_DELETE_TEMP_TABLES, main='main'))
@@ -1275,7 +1290,9 @@ class BigQueryBatchFileLoads(beam.PTransform):
         | "RemoveTempTables/DeduplicateTables" >> beam.GroupByKey()
         | "RemoveTempTables/GetTableNames" >> beam.Keys()
         | "RemoveTempTables/Delete" >> beam.ParDo(
-            DeleteTablesFn(self.test_client)))
+            DeleteTablesFn(
+                test_client=self.test_client,
+                quota_project_id=self.quota_project_id)))
 
     # Load data directly to destination table
     destination_load_job_ids_pc = (
@@ -1290,7 +1307,8 @@ class BigQueryBatchFileLoads(beam.PTransform):
                 additional_bq_parameters=self.additional_bq_parameters,
                 source_format=self._temp_file_format,
                 step_name=step_name,
-                load_job_project_id=self.load_job_project_id),
+                load_job_project_id=self.load_job_project_id,
+                quota_project_id=self.quota_project_id),
             load_job_name_pcv,
             *self.schema_side_inputs).with_outputs(
                 TriggerLoadJobs.ONGOING_JOBS, main='main')
@@ -1305,6 +1323,8 @@ class BigQueryBatchFileLoads(beam.PTransform):
   def expand(self, pcoll):
     p = pcoll.pipeline
     self.project = self.project or 
p.options.view_as(GoogleCloudOptions).project
+    self.quota_project_id = bigquery_tools._quota_project_id_from_options(
+        p.options)
     try:
       step_name = self.label
     except AttributeError:
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py 
b/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py
index 7ae0ccc8e32..7ccda7ecad8 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py
@@ -19,6 +19,7 @@
 
 # pytype: skip-file
 
+import contextlib
 import logging
 import os
 import secrets
@@ -1259,6 +1260,52 @@ class TestBigQueryFileLoads(_TestCaseWithTempDirCleanUp):
       assert_that(jobs, equal_to(expected_jobs), label='CheckJobs')
 
 
+class TestFileLoadsQuotaProject(_TestCaseWithTempDirCleanUp):
+  """Tests that the file loads DoFns attribute quota to the quota project."""
+  @parameterized.expand([
+      param(dofn=bqfl.UpdateDestinationSchema, quota_attr='_quota_project_id'),
+      param(dofn=bqfl.TriggerCopyJobs, quota_attr='quota_project_id'),
+      param(dofn=bqfl.TriggerLoadJobs, quota_attr='quota_project_id'),
+      param(dofn=bqfl.DeleteTablesFn, quota_attr='quota_project_id'),
+  ])
+  def test_start_bundle_passes_quota_project(self, dofn, quota_attr):
+    fn = dofn(quota_project_id='my-billing-project')
+    self.assertEqual(getattr(fn, quota_attr), 'my-billing-project')
+    with mock.patch.object(bigquery_tools, 'BigQueryWrapper') as mock_wrapper:
+      fn.start_bundle()
+    self.assertEqual(
+        mock_wrapper.call_args.kwargs['quota_project_id'], 
'my-billing-project')
+
+  def test_expand_resolves_quota_project_from_options(self):
+    options = PipelineOptions(['--quota_project_id=my-billing-project'])
+    transform = bqfl.BigQueryBatchFileLoads(
+        'project:dataset.table',
+        custom_gcs_temp_location=self._new_tempdir(),
+        test_client=mock.Mock(),
+        validate=False,
+        temp_file_format=bigquery_tools.FileFormat.JSON)
+    p = TestPipeline(options=options)
+    dofns = [
+        bqfl.UpdateDestinationSchema,
+        bqfl.TriggerCopyJobs,
+        bqfl.TriggerLoadJobs,
+        bqfl.DeleteTablesFn
+    ]
+    with contextlib.ExitStack() as stack:
+      mocks = [
+          stack.enter_context(
+              mock.patch.object(
+                  dofn, '__init__', autospec=True, side_effect=dofn.__init__))
+          for dofn in dofns
+      ]
+      _ = p | beam.Create(_ELEMENTS) | transform
+    self.assertEqual(transform.quota_project_id, 'my-billing-project')
+    for dofn, m in zip(dofns, mocks):
+      self.assertTrue(m.call_args_list, dofn.__name__)
+      for c in m.call_args_list:
+        self.assertEqual(c.kwargs['quota_project_id'], 'my-billing-project')
+
+
 class BigQueryFileLoadsIT(unittest.TestCase):
 
   BIG_QUERY_SCHEMA = (
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_read_internal.py 
b/sdks/python/apache_beam/io/gcp/bigquery_read_internal.py
index 136b3cc56b7..2fa95a3d179 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_read_internal.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_read_internal.py
@@ -142,11 +142,13 @@ class _PassThroughThenCleanupTempDatasets(PTransform):
 
     Utilizes readiness of PCollection to trigger DoFn.
   """
-  def __init__(self, side_input=None):
+  def __init__(self, side_input=None, quota_project_id=None):
     self.side_input = side_input
+    self.quota_project_id = quota_project_id
 
   def expand(self, input):
     pipeline_options = input.pipeline.options
+    quota_project_id = self.quota_project_id
 
     class PassThrough(beam.DoFn):
       def process(self, element):
@@ -154,8 +156,9 @@ class _PassThroughThenCleanupTempDatasets(PTransform):
 
     class CleanUpProjects(beam.DoFn):
       def process(self, unused_element, unused_signal, pipeline_details):
-        bq = bigquery_tools.BigQueryWrapper.from_pipeline_options(
-            pipeline_options)
+        bq = bigquery_tools.BigQueryWrapper(
+            pipeline_options=pipeline_options,
+            quota_project_id=quota_project_id)
         pipeline_details = pipeline_details[0]
         if 'temp_table_ref' in pipeline_details.keys():
           temp_table_ref = pipeline_details['temp_table_ref']
@@ -251,7 +254,7 @@ class _BigQueryReadSplit(beam.transforms.DoFn):
   def start_bundle(self):
     self.bq = bigquery_tools.BigQueryWrapper(
         temp_dataset_id=self._get_temp_dataset_id(),
-        client=bigquery_tools.BigQueryWrapper._bigquery_client(self.options))
+        pipeline_options=self.options)
 
   def process(self,
               element: 'ReadFromBigQueryRequest') -> Iterable[BoundedSource]:
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_test.py 
b/sdks/python/apache_beam/io/gcp/bigquery_test.py
index 22b617fa874..ae03104612c 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_test.py
@@ -53,6 +53,7 @@ from apache_beam.io.gcp.bigquery import WriteToBigQuery
 from apache_beam.io.gcp.bigquery import _StreamToBigQuery
 from apache_beam.io.gcp.bigquery_read_internal import _BigQueryReadSplit
 from apache_beam.io.gcp.bigquery_read_internal import _JsonToDictCoder
+from apache_beam.io.gcp.bigquery_read_internal import 
_PassThroughThenCleanupTempDatasets
 from apache_beam.io.gcp.bigquery_read_internal import 
bigquery_export_destination_uri
 from apache_beam.io.gcp.bigquery_tools import JSON_COMPLIANCE_ERROR
 from apache_beam.io.gcp.bigquery_tools import BigQueryWrapper
@@ -65,6 +66,7 @@ from apache_beam.io.gcp.tests.bigquery_matcher import 
BigqueryFullResultStreamin
 from apache_beam.io.gcp.tests.bigquery_matcher import BigQueryTableMatcher
 from apache_beam.metrics.metric import Lineage
 from apache_beam.options import value_provider
+from apache_beam.options.pipeline_options import GoogleCloudOptions
 from apache_beam.options.pipeline_options import PipelineOptions
 from apache_beam.options.pipeline_options import StandardOptions
 from apache_beam.options.value_provider import RuntimeValueProvider
@@ -338,6 +340,10 @@ class TestJsonToDictCoder(unittest.TestCase):
 class TestReadFromBigQuery(unittest.TestCase):
   @classmethod
   def setUpClass(cls):
+    cls.env_patch = mock.patch.dict(
+        os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'})
+    cls.env_patch.start()
+
     class UserDefinedOptions(PipelineOptions):
       @classmethod
       def _add_argparse_args(cls, parser):
@@ -351,6 +357,7 @@ class TestReadFromBigQuery(unittest.TestCase):
 
   @classmethod
   def tearDownClass(cls):
+    cls.env_patch.stop()
     # Unset the option added in setupClass to avoid interfere with other tests.
     # Force a gc so PipelineOptions.__subclass__() no longer contains it.
     del cls.UserDefinedOptions
@@ -879,6 +886,343 @@ class TestReadFromBigQuery(unittest.TestCase):
     mock_convert.assert_called_once_with(schema, None)
 
 
[email protected](
+    HttpError is None or gcp_bigquery is None,
+    'GCP dependencies are not installed')
+class TestReadFromBigQueryQuotaProject(unittest.TestCase):
+  """Tests for quota_project_id in ReadFromBigQuery sources."""
+  def test_quota_project_id_from_pipeline_options(self):
+    """Test that quota_project_id is read from GoogleCloudOptions."""
+    options = PipelineOptions(['--quota_project_id=my-billing-project'])
+    gcp_options = options.view_as(GoogleCloudOptions)
+    self.assertEqual(gcp_options.quota_project_id, 'my-billing-project')
+
+  def test_quota_project_id_none_by_default_in_options(self):
+    """Test that quota_project_id is None by default in options."""
+    options = PipelineOptions([])
+    gcp_options = options.view_as(GoogleCloudOptions)
+    self.assertIsNone(gcp_options.quota_project_id)
+
+  def test_export_source_explicit_quota_project(self):
+    """Test that explicit quota_project_id is stored in
+    _CustomBigQuerySource."""
+    source = beam_bq._CustomBigQuerySource(
+        method=ReadFromBigQuery.Method.EXPORT,
+        table='project:dataset.table',
+        quota_project_id='my-billing-project')
+    self.assertEqual(source.quota_project_id, 'my-billing-project')
+    self.assertEqual(source._get_quota_project_id(), 'my-billing-project')
+
+  def test_export_source_gets_quota_from_options(self):
+    """Test that _CustomBigQuerySource falls back to options for
+    quota_project_id."""
+    options = PipelineOptions(['--quota_project_id=my-billing-project'])
+    source = beam_bq._CustomBigQuerySource(
+        method=ReadFromBigQuery.Method.EXPORT,
+        table='project:dataset.table',
+        pipeline_options=options)
+    self.assertEqual(source._get_quota_project_id(), 'my-billing-project')
+
+  def test_export_source_explicit_overrides_options(self):
+    """Test that explicit quota_project_id overrides options."""
+    options = PipelineOptions(['--quota_project_id=options-project'])
+    source = beam_bq._CustomBigQuerySource(
+        method=ReadFromBigQuery.Method.EXPORT,
+        table='project:dataset.table',
+        pipeline_options=options,
+        quota_project_id='explicit-project')
+    self.assertEqual(source._get_quota_project_id(), 'explicit-project')
+
+  def test_storage_source_explicit_quota_project(self):
+    """Test that explicit quota_project_id is stored in
+    _CustomBigQueryStorageSource."""
+    source = beam_bq._CustomBigQueryStorageSource(
+        method=ReadFromBigQuery.Method.DIRECT_READ,
+        table='project:dataset.table',
+        quota_project_id='my-billing-project')
+    self.assertEqual(source.quota_project_id, 'my-billing-project')
+    self.assertEqual(source._get_quota_project_id(), 'my-billing-project')
+
+  def test_storage_source_gets_quota_from_options(self):
+    """Test that _CustomBigQueryStorageSource falls back to options."""
+    options = PipelineOptions(['--quota_project_id=my-billing-project'])
+    source = beam_bq._CustomBigQueryStorageSource(
+        method=ReadFromBigQuery.Method.DIRECT_READ,
+        table='project:dataset.table',
+        pipeline_options=options)
+    self.assertEqual(source._get_quota_project_id(), 'my-billing-project')
+
+  @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client')
+  def test_export_source_split_passes_quota_to_client(self, mock_bq_client):
+    """split() must pass the transform-level quota_project_id to the BigQuery
+    client, not only the --quota_project_id pipeline option."""
+    mock_bq_client.side_effect = RuntimeError('stop')
+    source = beam_bq._CustomBigQuerySource(
+        method=ReadFromBigQuery.Method.EXPORT,
+        table='project:dataset.table',
+        quota_project_id='my-billing-project')
+    with self.assertRaises(RuntimeError):
+      list(source.split(desired_bundle_size=0))
+    mock_bq_client.assert_called_once()
+    self.assertIsInstance(mock_bq_client.call_args.args[0], PipelineOptions)
+    self.assertEqual(
+        mock_bq_client.call_args.kwargs['quota_project_id'],
+        'my-billing-project')
+
+  @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client')
+  def test_storage_source_split_passes_quota_to_client(self, mock_bq_client):
+    """split() must pass the transform-level quota_project_id to the BigQuery
+    client, not only the --quota_project_id pipeline option."""
+    mock_bq_client.side_effect = RuntimeError('stop')
+    source = beam_bq._CustomBigQueryStorageSource(
+        method=ReadFromBigQuery.Method.DIRECT_READ,
+        table='project:dataset.table',
+        quota_project_id='my-billing-project')
+    with self.assertRaises(RuntimeError):
+      list(source.split(desired_bundle_size=0))
+    mock_bq_client.assert_called_once()
+    self.assertIsInstance(mock_bq_client.call_args.args[0], PipelineOptions)
+    self.assertEqual(
+        mock_bq_client.call_args.kwargs['quota_project_id'],
+        'my-billing-project')
+
+  @mock.patch.object(bigquery_tools, 'create_bq_storage_client')
+  @mock.patch.object(bigquery_tools.BigQueryWrapper, 'get_table')
+  @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client')
+  def test_storage_source_split_passes_quota_to_storage_client(
+      self, unused_mock_bq_client, mock_get_table, mock_create_storage_client):
+    """split() must create the Storage Read client with the quota project
+    and pass it on to each stream source."""
+    mock_get_table.return_value = mock.Mock(numBytes=5)
+    stream = mock.Mock()
+    stream.name = 'projects/p/locations/l/sessions/s/streams/stream1'
+    mock_create_storage_client.return_value.create_read_session.return_value = 
(
+        mock.Mock(streams=[stream]))
+    source = beam_bq._CustomBigQueryStorageSource(
+        method=ReadFromBigQuery.Method.DIRECT_READ,
+        table='project:dataset.table',
+        quota_project_id='my-billing-project')
+
+    bundles = list(source.split(desired_bundle_size=1 << 20))
+
+    mock_create_storage_client.assert_called_once_with('my-billing-project')
+    self.assertEqual(len(bundles), 1)
+    self.assertEqual(bundles[0].source.quota_project_id, 'my-billing-project')
+
+  @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client')
+  def test_export_source_estimate_size_passes_quota_to_client(
+      self, mock_bq_client):
+    """estimate_size() must attribute quota too, not just split()."""
+    mock_bq_client.side_effect = RuntimeError('stop')
+    source = beam_bq._CustomBigQuerySource(
+        method=ReadFromBigQuery.Method.EXPORT,
+        table='project:dataset.table',
+        quota_project_id='my-billing-project')
+    with self.assertRaises(RuntimeError):
+      source.estimate_size()
+    self.assertEqual(
+        mock_bq_client.call_args.kwargs['quota_project_id'],
+        'my-billing-project')
+
+  @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client')
+  def test_storage_source_estimate_size_passes_quota_to_client(
+      self, mock_bq_client):
+    """estimate_size() must attribute quota too, not just split()."""
+    mock_bq_client.side_effect = RuntimeError('stop')
+    source = beam_bq._CustomBigQueryStorageSource(
+        method=ReadFromBigQuery.Method.DIRECT_READ,
+        table='project:dataset.table',
+        quota_project_id='my-billing-project')
+    with self.assertRaises(RuntimeError):
+      source.estimate_size()
+    self.assertEqual(
+        mock_bq_client.call_args.kwargs['quota_project_id'],
+        'my-billing-project')
+
+  @mock.patch(
+      'apache_beam.io.gcp.bigquery.bigquery_schema_tools'
+      '.convert_to_usertype')
+  @mock.patch.object(bigquery_tools, 'BigQueryWrapper')
+  def test_output_type_schema_fetch_passes_quota_to_client(
+      self, mock_wrapper, mock_convert):
+    """The BEAM_ROW schema lookup is a BigQuery API call of this transform, so
+    it must be attributed to the quota project as well."""
+    mock_convert.return_value = beam.Map(lambda x: x)
+    transform = ReadFromBigQuery(
+        table='project:dataset.table',
+        output_type='BEAM_ROW',
+        quota_project_id='my-billing-project')
+    transform._expand_output_type(mock.MagicMock())
+
+    self.assertEqual(
+        mock_wrapper.call_args.kwargs['quota_project_id'], 
'my-billing-project')
+
+  @mock.patch.object(bigquery_tools, 'BigQueryWrapper')
+  def test_direct_read_cleanup_passes_quota_to_client(self, mock_wrapper):
+    """The DIRECT_READ temp-dataset cleanup is a BigQuery API call of this
+    transform, so it must be attributed to the quota project as well."""
+    pipeline_details = {
+        'project_id': 'project', 'bigquery_dataset_labels': {
+            'k': 'v'
+        }
+    }
+    with TestPipeline() as p:
+      side_input = beam.pvalue.AsList(
+          p | 'Details' >> beam.Create([pipeline_details]))
+      _ = (
+          p | beam.Create([1])
+          | _PassThroughThenCleanupTempDatasets(
+              side_input, quota_project_id='my-billing-project'))
+
+    self.assertEqual(
+        mock_wrapper.call_args.kwargs['quota_project_id'], 
'my-billing-project')
+    self.assertIsNotNone(mock_wrapper.call_args.kwargs['pipeline_options'])
+
+  
@mock.patch('apache_beam.io.gcp.bigquery._PassThroughThenCleanupTempDatasets')
+  def test_direct_read_wires_quota_project_into_cleanup(self, mock_cleanup):
+    mock_cleanup.return_value = beam.Map(lambda x: x)
+    transform = ReadFromBigQuery(
+        table='project:dataset.table',
+        method=ReadFromBigQuery.Method.DIRECT_READ,
+        quota_project_id='my-billing-project')
+    p = TestPipeline()
+    transform._expand_direct_read(beam.pvalue.PBegin(p))
+
+    self.assertEqual(
+        mock_cleanup.call_args.kwargs['quota_project_id'], 
'my-billing-project')
+
+  def test_quota_project_id_in_export_source_display_data(self):
+    """Test that quota_project_id appears in display data for export source."""
+    source = beam_bq._CustomBigQuerySource(
+        method=ReadFromBigQuery.Method.EXPORT,
+        table='project:dataset.table',
+        quota_project_id='my-billing-project')
+    items = {i.key: i.value for i in DisplayData.create_from(source).items}
+    self.assertEqual(items['quota_project_id'], 'my-billing-project')
+
+  def test_quota_project_id_in_direct_read_source_display_data(self):
+    """Test that quota_project_id appears in display data for DIRECT_READ."""
+    source = beam_bq._CustomBigQueryStorageSource(
+        method=ReadFromBigQuery.Method.DIRECT_READ,
+        table='project:dataset.table',
+        quota_project_id='my-billing-project')
+    items = {i.key: i.value for i in DisplayData.create_from(source).items}
+    self.assertEqual(items['quota_project_id'], 'my-billing-project')
+
+  def test_quota_project_id_absent_from_display_data_when_not_set(self):
+    """Test that quota_project_id is dropped from display data when not set."""
+    options = PipelineOptions([])
+    sources = [
+        beam_bq._CustomBigQuerySource(
+            method=ReadFromBigQuery.Method.EXPORT,
+            table='project:dataset.table',
+            pipeline_options=options),
+        beam_bq._CustomBigQueryStorageSource(
+            method=ReadFromBigQuery.Method.DIRECT_READ,
+            table='project:dataset.table',
+            pipeline_options=options)
+    ]
+    for source in sources:
+      keys = {i.key for i in DisplayData.create_from(source).items}
+      self.assertNotIn('quota_project_id', keys)
+
+  def test_stream_source_stores_quota_project_id(self):
+    """Test that quota_project_id is stored in
+    _CustomBigQueryStorageStreamSource."""
+    stream_source = beam_bq._CustomBigQueryStorageStreamSource(
+        read_stream_name='projects/p/locations/l/sessions/s/streams/stream1',
+        use_native_datetime=True,
+        timeout=30.0,
+        quota_project_id='my-billing-project')
+    self.assertEqual(stream_source.quota_project_id, 'my-billing-project')
+
+  def test_stream_source_quota_project_id_none_by_default(self):
+    """Test that quota_project_id is None by default in stream source."""
+    stream_source = beam_bq._CustomBigQueryStorageStreamSource(
+        read_stream_name='projects/p/locations/l/sessions/s/streams/stream1')
+    self.assertIsNone(stream_source.quota_project_id)
+
+  def test_stream_source_split_preserves_quota_project_id(self):
+    """Test that split() preserves quota_project_id."""
+    stream_source = beam_bq._CustomBigQueryStorageStreamSource(
+        read_stream_name='projects/p/locations/l/sessions/s/streams/stream1',
+        use_native_datetime=True,
+        timeout=30.0,
+        quota_project_id='my-billing-project')
+    bundle = stream_source.split(desired_bundle_size=0)
+    self.assertEqual(bundle.source.quota_project_id, 'my-billing-project')
+    self.assertEqual(bundle.source.timeout, 30.0)
+    self.assertEqual(bundle.source.use_native_datetime, True)
+
+  @mock.patch('apache_beam.io.gcp.bigquery_tools.create_bq_storage_client')
+  def test_stream_source_read_arrow_uses_quota_project(
+      self, mock_create_client):
+    """Test that read_arrow() uses create_bq_storage_client
+    with quota_project_id."""
+    mock_client = mock.MagicMock()
+    mock_create_client.return_value = mock_client
+    # Mock read_rows to return empty iterator
+    mock_client.read_rows.return_value.rows.return_value = iter([])
+
+    stream_source = beam_bq._CustomBigQueryStorageStreamSource(
+        read_stream_name='projects/p/locations/l/sessions/s/streams/stream1',
+        use_native_datetime=True,
+        quota_project_id='my-billing-project')
+    # Consume the iterator
+    list(stream_source.read_arrow())
+
+    mock_create_client.assert_called_once_with('my-billing-project')
+
+  @mock.patch('apache_beam.io.gcp.bigquery_tools.create_bq_storage_client')
+  def test_stream_source_read_avro_uses_quota_project(self, 
mock_create_client):
+    """Test that read_avro() uses create_bq_storage_client
+    with quota_project_id."""
+    mock_client = mock.MagicMock()
+    mock_create_client.return_value = mock_client
+    # Mock read_rows to return empty iterator
+    mock_client.read_rows.return_value = iter([])
+
+    stream_source = beam_bq._CustomBigQueryStorageStreamSource(
+        read_stream_name='projects/p/locations/l/sessions/s/streams/stream1',
+        use_native_datetime=False,
+        quota_project_id='my-billing-project')
+    # Consume the iterator
+    list(stream_source.read_avro())
+
+    mock_create_client.assert_called_once_with('my-billing-project')
+
+
[email protected](HttpError is None, 'GCP dependencies are not installed')
+class TestWriteToBigQueryQuotaProject(unittest.TestCase):
+  """Tests for quota_project_id in the BigQuery streaming insert path."""
+  @mock.patch.object(bigquery_tools, 'BigQueryWrapper')
+  def test_write_fn_start_bundle_passes_quota_project(self, mock_wrapper):
+    fn = beam_bq.BigQueryWriteFn(
+        batch_size=1, quota_project_id='my-billing-project')
+    fn.start_bundle()
+    self.assertEqual(
+        mock_wrapper.call_args.kwargs['quota_project_id'], 
'my-billing-project')
+
+  def test_stream_to_bigquery_resolves_quota_project_from_options(self):
+    options = PipelineOptions(['--quota_project_id=my-billing-project'])
+    p = TestPipeline(options=options)
+    with mock.patch.object(
+        beam_bq.BigQueryWriteFn,
+        '__init__',
+        autospec=True,
+        side_effect=beam_bq.BigQueryWriteFn.__init__) as mock_write_fn:
+      _ = (
+          p
+          | beam.Create([])
+          | WriteToBigQuery(
+              'project:dataset.table',
+              schema='a:STRING',
+              method=WriteToBigQuery.Method.STREAMING_INSERTS))
+    self.assertEqual(
+        mock_write_fn.call_args.kwargs['quota_project_id'],
+        'my-billing-project')
+
+
 @unittest.skipIf(HttpError is None, 'GCP dependencies are not installed')
 class TestBigQuerySink(unittest.TestCase):
   def test_table_spec_display_data(self):
@@ -920,10 +1264,14 @@ class TestWriteToBigQuery(unittest.TestCase):
       os.remove('insert_calls2')
 
   def setUp(self):
+    self.env_patch = mock.patch.dict(
+        os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'})
+    self.env_patch.start()
     self._cleanup_files()
 
   def tearDown(self):
     self._cleanup_files()
+    self.env_patch.stop()
 
   def test_noop_schema_parsing(self):
     expected_table_schema = None
@@ -1481,6 +1829,13 @@ class TestWriteToBigQuery(unittest.TestCase):
     HttpError is None or exceptions is None,
     'GCP dependencies are not installed')
 class BigQueryStreamingInsertsErrorHandling(unittest.TestCase):
+  def setUp(self):
+    self.env_patch = mock.patch.dict(
+        os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'})
+    self.env_patch.start()
+
+  def tearDown(self):
+    self.env_patch.stop()
 
   # Running tests with a variety of exceptions from  https://googleapis.dev
   #     
/python/google-api-core/latest/_modules/google/api_core/exceptions.html.
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_tools.py 
b/sdks/python/apache_beam/io/gcp/bigquery_tools.py
index cc1a16a659f..d1412d0f855 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_tools.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_tools.py
@@ -36,6 +36,7 @@ import logging
 import re
 import sys
 import time
+import urllib.parse
 import uuid
 from json.decoder import JSONDecodeError
 from typing import Optional
@@ -59,6 +60,7 @@ from apache_beam.io.gcp.internal.clients import bigquery
 from apache_beam.metrics import monitoring_infos
 from apache_beam.metrics.metric import Metrics
 from apache_beam.options import value_provider
+from apache_beam.options.pipeline_options import GoogleCloudOptions
 from apache_beam.options.pipeline_options import PipelineOptions
 from apache_beam.transforms import DoFn
 from apache_beam.typehints.row_type import RowTypeConstraint
@@ -80,6 +82,11 @@ except Exception:
   gcp_bigquery = None
   pass
 
+try:
+  from google.cloud import bigquery_storage_v1 as bq_storage
+except ImportError:
+  bq_storage = None
+
 try:
   from orjson import dumps as fast_json_dumps
   from orjson import loads as fast_json_loads
@@ -419,6 +426,58 @@ def _build_dataset_encryption_config(kms_key):
   return bigquery.EncryptionConfiguration(kmsKeyName=kms_key)
 
 
+def _quota_project_id_from_options(pipeline_options):
+  """Returns --quota_project_id from pipeline options, if any."""
+  if pipeline_options is None:
+    return None
+  return pipeline_options.view_as(GoogleCloudOptions).quota_project_id
+
+
+def create_bq_storage_client(quota_project_id=None):
+  """Create a BigQueryReadClient with optional quota project.
+
+  Args:
+    quota_project_id: Optional GCP project ID to use for quota and billing.
+      The client applies it to the credentials it resolves itself.
+
+  Returns:
+    A BigQueryReadClient instance.
+  """
+  if not quota_project_id:
+    return bq_storage.BigQueryReadClient()
+  return bq_storage.BigQueryReadClient(
+      client_options={'quota_project_id': quota_project_id})
+
+
+class _HttpWithHeaders(object):
+  """Wraps an httplib2.Http to add fixed headers to requests to given hosts.
+
+  Sits below the credentials layer, so the headers are applied on every
+  attempt and take precedence over headers set by the credentials. The
+  credentials refresh their token through the same Http, so requests to other
+  hosts (token endpoint, metadata server, IAM Credentials API) are passed
+  through unchanged.
+  """
+  def __init__(self, http, headers, hosts):
+    object.__setattr__(self, '_http', http)
+    object.__setattr__(self, '_headers', headers)
+    object.__setattr__(self, '_hosts', frozenset(hosts))
+
+  def request(self, uri, method='GET', body=None, headers=None, **kwargs):
+    if urllib.parse.urlsplit(uri).hostname in self._hosts:
+      headers = dict(headers or {})
+      headers.update(self._headers)
+    return self._http.request(uri, method, body=body, headers=headers, 
**kwargs)
+
+  def __getattr__(self, name):
+    if name == '_http':  # object created without __init__ (copy/unpickle)
+      raise AttributeError(name)
+    return getattr(self._http, name)
+
+  def __setattr__(self, name, value):
+    setattr(self._http, name, value)
+
+
 class BigQueryWrapper(object):
   """BigQuery client wrapper with utilities for querying.
 
@@ -427,8 +486,10 @@ class BigQueryWrapper(object):
   In addition, it offers various functions used both in sources and sinks
   (e.g., find and create tables, query a table, etc.).
 
-  Note that client parameter in constructor is only for testing purposes and
-  should not be used in production code.
+  Note that the client and gcp_client parameters in the constructor are only
+  for testing purposes and should not be used in production code. Production
+  code should pass pipeline_options (and optionally quota_project_id) so that
+  every client is built here with the right quota attribution.
   """
 
   # If updating following names, also update the corresponding pydocs in
@@ -438,16 +499,29 @@ class BigQueryWrapper(object):
 
   HISTOGRAM_METRIC_LOGGER = MetricLogger()
 
-  def __init__(self, client=None, temp_dataset_id=None, temp_table_ref=None):
-    self.client = client or BigQueryWrapper._bigquery_client(PipelineOptions())
-    self.gcp_bq_client = client or gcp_bigquery.Client(
-        client_info=ClientInfo(
-            user_agent="apache-beam-%s" % apache_beam.__version__))
+  def __init__(
+      self,
+      client=None,
+      temp_dataset_id=None,
+      temp_table_ref=None,
+      quota_project_id=None,
+      *,
+      gcp_client=None,
+      pipeline_options=None):
+    self.quota_project_id = (
+        quota_project_id or _quota_project_id_from_options(pipeline_options))
+    self.client = client or BigQueryWrapper._bigquery_client(
+        pipeline_options or PipelineOptions(),
+        quota_project_id=self.quota_project_id)
+    # Test-only overrides. `client` also stands in for the 
google-cloud-bigquery
+    # client, which several tests rely on; pass `gcp_client` as well as 
`client`
+    # to override only that one.
+    self._gcp_bq_client = gcp_client or client
 
     self._unique_row_id = 0
     # For testing scenarios where we pass in a client we do not want a
     # randomized prefix for row IDs.
-    self._row_id_prefix = '' if client else uuid.uuid4()
+    self._row_id_prefix = '' if (client or gcp_client) else uuid.uuid4()
     self._latency_histogram_metric = Metrics.histogram(
         self.__class__,
         'latency_histogram_ms',
@@ -474,6 +548,17 @@ class BigQueryWrapper(object):
 
     self.created_temp_dataset = False
 
+  @property
+  def gcp_bq_client(self):
+    """The google-cloud-bigquery client, created on first use.
+
+    Only the streaming insert path needs it, so it is not built eagerly.
+    """
+    if self._gcp_bq_client is None:
+      self._gcp_bq_client = BigQueryWrapper._gcp_bigquery_client(
+          quota_project_id=self.quota_project_id)
+    return self._gcp_bq_client
+
   @property
   def unique_row_id(self):
     """Returns a unique row ID (str) used to avoid multiple insertions.
@@ -1490,19 +1575,70 @@ class BigQueryWrapper(object):
 
   @staticmethod
   def from_pipeline_options(pipeline_options: PipelineOptions):
-    return BigQueryWrapper(
-        client=BigQueryWrapper._bigquery_client(pipeline_options))
+    """Create a BigQueryWrapper from pipeline options.
+
+    Args:
+      pipeline_options: Pipeline options containing GCP configuration.
+        The quota_project_id is read from GoogleCloudOptions if set.
+    """
+    return BigQueryWrapper(pipeline_options=pipeline_options)
 
   @staticmethod
-  def _bigquery_client(pipeline_options: PipelineOptions):
+  def _bigquery_client(
+      pipeline_options: PipelineOptions, quota_project_id: str = None):
+    """Create a BigQuery API client from pipeline options.
+
+    Args:
+      pipeline_options: Pipeline options for credentials.
+      quota_project_id: Optional quota project ID. If not provided, will be
+        extracted from pipeline_options.
+    """
+    credentials = auth.get_service_credentials(pipeline_options)
+    # Use explicit quota_project_id if provided, otherwise get from options
+    quota_project_id = quota_project_id or _quota_project_id_from_options(
+        pipeline_options)
+    http = get_new_http()
+    if quota_project_id:
+      if credentials is None:
+        # Ignoring the request would silently bill a different project.
+        raise ValueError(
+            'quota_project_id was set to %r, but no credentials were found to '
+            'apply it to.' % quota_project_id)
+      # Send the quota project as a request header rather than deriving new
+      # credentials: credentials.with_quota_project() returns a copy without
+      # the cached token, and this keeps the shared credentials untouched.
+      # The header is set below the credentials layer so that it is applied on
+      # every attempt and wins over a quota project the credentials carry.
+      http = _HttpWithHeaders(
+          http, {'x-goog-user-project': quota_project_id},
+          hosts=[
+              urllib.parse.urlsplit(url).hostname for url in (
+                  bigquery.BigqueryV2.BASE_URL,
+                  bigquery.BigqueryV2.MTLS_BASE_URL)
+          ])
     return bigquery.BigqueryV2(
-        http=get_new_http(),
-        credentials=auth.get_service_credentials(pipeline_options),
+        http=http,
+        credentials=credentials,
         response_encoding='utf8',
         additional_http_headers={
             "user-agent": "apache-beam-%s" % apache_beam.__version__
         })
 
+  @staticmethod
+  def _gcp_bigquery_client(quota_project_id: str = None):
+    """Create a google-cloud-bigquery Client with optional quota project.
+
+    The client applies the quota project to the credentials it resolves
+    itself, so no credentials are derived here.
+    """
+    client_options = None
+    if quota_project_id:
+      client_options = {'quota_project_id': quota_project_id}
+    return gcp_bigquery.Client(
+        client_options=client_options,
+        client_info=ClientInfo(
+            user_agent="apache-beam-%s" % apache_beam.__version__))
+
 
 class RowAsDictJsonCoder(coders.Coder):
   """A coder for a table row (represented as a dict) to/from a JSON string.
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py 
b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py
index 9f8b78ee5c3..de4f3e4066a 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py
@@ -23,6 +23,7 @@ import io
 import json
 import logging
 import math
+import os
 import re
 import unittest
 from typing import Optional
@@ -35,10 +36,12 @@ import pytz
 from parameterized import parameterized
 
 import apache_beam as beam
+from apache_beam.io.gcp import bigquery_tools
 from apache_beam.io.gcp import resource_identifiers
 from apache_beam.io.gcp.bigquery_tools import JSON_COMPLIANCE_ERROR
 from apache_beam.io.gcp.bigquery_tools import AvroRowWriter
 from apache_beam.io.gcp.bigquery_tools import BigQueryJobTypes
+from apache_beam.io.gcp.bigquery_tools import BigQueryWrapper
 from apache_beam.io.gcp.bigquery_tools import JsonRowWriter
 from apache_beam.io.gcp.bigquery_tools import RowAsDictJsonCoder
 from apache_beam.io.gcp.bigquery_tools import beam_row_from_dict
@@ -299,14 +302,16 @@ class TestBigQueryWrapper(unittest.TestCase):
   @mock.patch('google.cloud._http.JSONConnection.http')
   def test_user_agent_insert_all(
       self, http_mock, patched_skip_get_credentials, patched_sleep):
-    wrapper = beam.io.gcp.bigquery_tools.BigQueryWrapper()
-    try:
-      wrapper._insert_all_rows('p', 'd', 't', [{'name': 'any'}], None)
-    except:  # pylint: disable=bare-except
-      # Ignore errors. The errors come from the fact that we did not mock
-      # the response from the API, so the overall insert_all_rows call fails
-      # soon after the BQ API is called.
-      pass
+    # Set GOOGLE_CLOUD_PROJECT to ensure Client creation succeeds in test env
+    with mock.patch.dict(os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'}):
+      wrapper = beam.io.gcp.bigquery_tools.BigQueryWrapper()
+      try:
+        wrapper._insert_all_rows('p', 'd', 't', [{'name': 'any'}], None)
+      except:  # pylint: disable=bare-except
+        # Ignore errors. The errors come from the fact that we did not mock
+        # the response from the API, so the overall insert_all_rows call fails
+        # soon after the BQ API is called.
+        pass
     call = http_mock.request.mock_calls[-2]
     self.assertIn('apache-beam-', call[2]['headers']['User-Agent'])
 
@@ -1199,8 +1204,6 @@ class TestGeographyTypeSupport(unittest.TestCase):
 
   def test_geography_field_conversion(self):
     """Test that GEOGRAPHY fields are converted correctly."""
-    from apache_beam.io.gcp.bigquery_tools import BigQueryWrapper
-
     # Create a mock field with GEOGRAPHY type
     field = bigquery.TableFieldSchema()
     field.type = 'GEOGRAPHY'
@@ -1322,8 +1325,6 @@ class TestGeographyTypeSupport(unittest.TestCase):
 
   def test_geography_with_special_characters(self):
     """Test GEOGRAPHY values with special characters and geometries."""
-    from apache_beam.io.gcp.bigquery_tools import BigQueryWrapper
-
     field = bigquery.TableFieldSchema()
     field.type = 'GEOGRAPHY'
     field.name = 'complex_geo'
@@ -1502,6 +1503,213 @@ class TestTypeOverrides(unittest.TestCase):
     self.assertEqual(typehints_dict, [("data", Optional[dict])])
 
 
[email protected](HttpError is None, 'GCP dependencies are not installed')
+class TestBigQueryWrapperQuotaProject(unittest.TestCase):
+  """Tests for quota_project_id in BigQueryWrapper."""
+  @mock.patch(
+      'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._bigquery_client')
+  @mock.patch(
+      'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._gcp_bigquery_client')
+  def test_quota_project_id_stored(self, mock_gcp_client, mock_bq_client):
+    """Test that quota_project_id is stored in BigQueryWrapper."""
+    mock_bq_client.return_value = mock.Mock()
+    mock_gcp_client.return_value = mock.Mock()
+
+    wrapper = BigQueryWrapper(quota_project_id='my-billing-project')
+    self.assertEqual(wrapper.quota_project_id, 'my-billing-project')
+
+  @mock.patch(
+      'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._bigquery_client')
+  @mock.patch(
+      'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._gcp_bigquery_client')
+  def test_from_pipeline_options_reads_quota_from_options(
+      self, mock_gcp_client, mock_bq_client):
+    """Test from_pipeline_options reads quota_project_id from
+    GoogleCloudOptions."""
+    from apache_beam.options.pipeline_options import PipelineOptions
+
+    mock_bq_client.return_value = mock.Mock()
+    mock_gcp_client.return_value = mock.Mock()
+
+    options = PipelineOptions(['--quota_project_id=my-billing-project'])
+    wrapper = BigQueryWrapper.from_pipeline_options(options)
+
+    self.assertEqual(wrapper.quota_project_id, 'my-billing-project')
+
+  @mock.patch(
+      'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._bigquery_client')
+  @mock.patch(
+      'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._gcp_bigquery_client')
+  def test_from_pipeline_options_none_when_not_set(
+      self, mock_gcp_client, mock_bq_client):
+    """Test from_pipeline_options returns None when quota_project_id not 
set."""
+    from apache_beam.options.pipeline_options import PipelineOptions
+
+    mock_bq_client.return_value = mock.Mock()
+    mock_gcp_client.return_value = mock.Mock()
+
+    options = PipelineOptions([])
+    wrapper = BigQueryWrapper.from_pipeline_options(options)
+
+    self.assertIsNone(wrapper.quota_project_id)
+
+  def test_http_with_headers_sets_headers_and_delegates(self):
+    from apache_beam.io.gcp.bigquery_tools import _HttpWithHeaders
+    inner = mock.MagicMock()
+    inner.request.return_value = ('response', b'content')
+    inner.redirect_codes = {301, 302}
+    http = _HttpWithHeaders(
+        inner, {'x-goog-user-project': 'p'}, hosts=['example.com'])
+
+    result = http.request(
+        'https://example.com',
+        method='GET',
+        headers={
+            'x-goog-user-project': 'other', 'accept': 'json'
+        },
+        redirections=3)
+
+    self.assertEqual(result, ('response', b'content'))
+    inner.request.assert_called_once_with(
+        'https://example.com',
+        'GET',
+        body=None,
+        headers={
+            'x-goog-user-project': 'p', 'accept': 'json'
+        },
+        redirections=3)
+    # Attribute reads and writes reach the wrapped http.
+    self.assertEqual(http.redirect_codes, {301, 302})
+    http.redirect_codes = {301}
+    self.assertEqual(inner.redirect_codes, {301})
+
+  def test_http_with_headers_survives_copy(self):
+    import copy
+
+    from apache_beam.io.gcp.bigquery_tools import _HttpWithHeaders
+    inner = mock.MagicMock()
+    http = copy.copy(
+        _HttpWithHeaders(
+            inner, {'x-goog-user-project': 'p'}, hosts=['example.com']))
+
+    http.request('https://example.com')
+    inner.request.assert_called_once_with(
+        'https://example.com',
+        'GET',
+        body=None,
+        headers={'x-goog-user-project': 'p'})
+
+  def test_http_with_headers_skips_other_hosts(self):
+    from apache_beam.io.gcp.bigquery_tools import _HttpWithHeaders
+    inner = mock.MagicMock()
+    http = _HttpWithHeaders(
+        inner, {'x-goog-user-project': 'p'}, hosts=['example.com'])
+
+    http.request('https://oauth2.googleapis.com/token', headers={'a': 'b'})
+    inner.request.assert_called_once_with(
+        'https://oauth2.googleapis.com/token',
+        'GET',
+        body=None,
+        headers={'a': 'b'})
+
+  @mock.patch('apache_beam.io.gcp.bigquery_tools.bq_storage')
+  def test_create_bq_storage_client_with_quota_project(self, mock_bq_storage):
+    """The quota project is passed as a client option; the client applies it
+    to the credentials it resolves itself."""
+    bigquery_tools.create_bq_storage_client('my-billing-project')
+    mock_bq_storage.BigQueryReadClient.assert_called_once_with(
+        client_options={'quota_project_id': 'my-billing-project'})
+
+  @mock.patch('apache_beam.io.gcp.bigquery_tools.bq_storage')
+  def test_create_bq_storage_client_without_quota_project(
+      self, mock_bq_storage):
+    """Without a quota project the client is created with defaults."""
+    bigquery_tools.create_bq_storage_client(None)
+    mock_bq_storage.BigQueryReadClient.assert_called_once_with()
+
+  @mock.patch('apache_beam.io.gcp.bigquery_tools.auth.get_service_credentials')
+  def test_bigquery_client_raises_without_credentials(
+      self, mock_get_credentials):
+    """An explicit quota project must fail loudly rather than fall back to
+    apitools' own credential discovery, which would bill another project."""
+    from apache_beam.options.pipeline_options import PipelineOptions
+    mock_get_credentials.return_value = None
+
+    with self.assertRaisesRegex(ValueError, 'no credentials were found'):
+      BigQueryWrapper._bigquery_client(
+          PipelineOptions(), quota_project_id='requested-project')
+
+  @mock.patch('apache_beam.io.gcp.bigquery_tools.get_new_http')
+  @mock.patch('apache_beam.io.gcp.bigquery_tools.auth.get_service_credentials')
+  def test_bigquery_client_quota_project_wins_over_credentials(
+      self, mock_get_credentials, mock_get_new_http):
+    """The requested quota project must reach every BigQuery request,
+    including the retry after a 401 refresh, even when the credentials carry
+    their own, but not the token refresh requests."""
+    import httplib2
+    from google.auth import credentials as ga_credentials
+
+    from apache_beam.internal.gcp import auth
+    from apache_beam.options.pipeline_options import PipelineOptions
+
+    class CredentialsWithQuotaProject(ga_credentials.Credentials):
+      def __init__(self):
+        super().__init__()
+        self._quota_project_id = 'adc-project'
+
+      def refresh(self, request):
+        # Token refreshes go through the same Http as the API requests.
+        request('https://oauth2.googleapis.com/token', method='POST')
+        self.token = 'token'
+
+    seen_headers = []
+    token_request_headers = []
+    statuses = iter([401, 200])
+
+    class FakeHttp(object):
+      connections = {}
+      redirect_codes = set()
+
+      def request(self, uri, method='GET', body=None, headers=None, **kwargs):
+        if uri.startswith('https://oauth2.googleapis.com/'):
+          token_request_headers.append(dict(headers or {}))
+          return httplib2.Response({'status': 200}), b'{}'
+        seen_headers.append(dict(headers))
+        response = httplib2.Response({
+            'status': next(statuses), 'content-type': 'application/json'
+        })
+        return response, b'{}'
+
+    mock_get_new_http.return_value = FakeHttp()
+    mock_get_credentials.return_value = auth._ApitoolsCredentialsAdapter(
+        CredentialsWithQuotaProject())
+
+    client = BigQueryWrapper._bigquery_client(
+        PipelineOptions(), quota_project_id='requested-project')
+    client.projects.List(bigquery.BigqueryProjectsListRequest())
+
+    self.assertEqual(len(seen_headers), 2)
+    for headers in seen_headers:
+      self.assertEqual(headers['x-goog-user-project'], 'requested-project')
+      self.assertEqual(headers['authorization'], 'Bearer token')
+    # The quota project must not be sent to the token endpoint.
+    self.assertTrue(token_request_headers)
+    for headers in token_request_headers:
+      self.assertNotIn('x-goog-user-project', headers)
+
+  @mock.patch('apache_beam.io.gcp.bigquery_tools.gcp_bigquery.Client')
+  def test_gcp_bigquery_client_passes_quota_project(self, mock_client):
+    BigQueryWrapper._gcp_bigquery_client(quota_project_id='my-billing-project')
+    self.assertEqual(
+        mock_client.call_args.kwargs['client_options'],
+        {'quota_project_id': 'my-billing-project'})
+
+  @mock.patch('apache_beam.io.gcp.bigquery_tools.gcp_bigquery.Client')
+  def test_gcp_bigquery_client_no_quota_project_by_default(self, mock_client):
+    BigQueryWrapper._gcp_bigquery_client()
+    self.assertIsNone(mock_client.call_args.kwargs['client_options'])
+
+
 if __name__ == '__main__':
   logging.getLogger().setLevel(logging.INFO)
   unittest.main()
diff --git a/sdks/python/apache_beam/options/pipeline_options.py 
b/sdks/python/apache_beam/options/pipeline_options.py
index 08ba34dabee..f0406e2505b 100644
--- a/sdks/python/apache_beam/options/pipeline_options.py
+++ b/sdks/python/apache_beam/options/pipeline_options.py
@@ -1197,6 +1197,17 @@ class GoogleCloudOptions(PipelineOptions):
         action='store_true',
         help='Throttling counter in GcsIO is enabled by default. Set '
         '--no_gcsio_throttling_counter to avoid it.')
+    parser.add_argument(
+        '--quota_project_id',
+        default=None,
+        help='GCP project ID used for quota and billing attribution of Google '
+        'Cloud API requests (sets the x-goog-user-project header), if '
+        'different from the project the data resides in. Currently honored '
+        'by BigQuery IO transforms (ReadFromBigQuery, ReadAllFromBigQuery, '
+        'WriteToBigQuery with FILE_LOADS or STREAMING_INSERTS, '
+        'ReadBigQueryChangeHistory); STORAGE_WRITE_API (Java-backed) and '
+        'other GCP IOs do not use it yet. The credentials used must have the '
+        'serviceusage.services.use permission on this project.')
     parser.add_argument(
         '--enable_gcsio_blob_generation',
         default=False,

Reply via email to